Overhaul of the frontend on waiters, orders, and payment events
Manager Dashboard: product reorder/bulk actions, preference sub-choices UI, expanded reports with DateInput component, waiter management updates, order detail improvements, Docker config and backend dockerignore added. Backend: table groups, auto-numbering, has_active_order flag, expanded reporting endpoints, waiter zone management, user schema updates, system router additions, table router fixes. Waiter PWA: TableDetailPage order/payment event improvements. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>POS Manager</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
79
manager_dashboard/src/components/DateInput.jsx
Normal file
79
manager_dashboard/src/components/DateInput.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* DateInput / DateTimeInput
|
||||
*
|
||||
* Native date pickers display in the OS/browser locale (MM/DD/YYYY on en-US).
|
||||
* These wrappers overlay the native input with a visible DD/MM/YYYY display
|
||||
* while keeping the full native picker UX (click, keyboard, mobile wheel).
|
||||
*
|
||||
* Props mirror a plain <input>: value (YYYY-MM-DD or YYYY-MM-DDTHH:MM),
|
||||
* onChange (receives the same synthetic event), className.
|
||||
*/
|
||||
import { useRef } from 'react'
|
||||
|
||||
function formatDateGR(value) {
|
||||
// value is "YYYY-MM-DD"
|
||||
if (!value) return ''
|
||||
const [y, m, d] = value.split('-')
|
||||
if (!y || !m || !d) return value
|
||||
return `${d}/${m}/${y}`
|
||||
}
|
||||
|
||||
function formatDateTimeGR(value) {
|
||||
// value is "YYYY-MM-DDTHH:MM"
|
||||
if (!value) return ''
|
||||
const [datePart, timePart] = value.split('T')
|
||||
if (!datePart) return value
|
||||
const [y, m, d] = datePart.split('-')
|
||||
if (!y || !m || !d) return value
|
||||
return `${d}/${m}/${y}${timePart ? ' ' + timePart : ''}`
|
||||
}
|
||||
|
||||
export function DateInput({ value, onChange, className = '', ...rest }) {
|
||||
const ref = useRef(null)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative cursor-pointer ${className}`}
|
||||
onClick={() => ref.current?.showPicker?.()}
|
||||
>
|
||||
{/* Visible display */}
|
||||
<div className="absolute inset-0 flex items-center px-3 pointer-events-none z-10 bg-white rounded-lg text-sm text-gray-800">
|
||||
{value ? formatDateGR(value) : <span className="text-gray-400">ΗΗ/ΜΜ/ΕΕΕΕ</span>}
|
||||
</div>
|
||||
{/* Native input — invisible but functional (provides the picker) */}
|
||||
<input
|
||||
ref={ref}
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="opacity-0 w-full h-full absolute inset-0 cursor-pointer"
|
||||
tabIndex={0}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DateTimeInput({ value, onChange, className = '', ...rest }) {
|
||||
const ref = useRef(null)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative cursor-pointer ${className}`}
|
||||
onClick={() => ref.current?.showPicker?.()}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center px-3 pointer-events-none z-10 bg-white rounded-lg text-sm text-gray-800">
|
||||
{value ? formatDateTimeGR(value) : <span className="text-gray-400">ΗΗ/ΜΜ/ΕΕΕΕ ΩΩ:ΛΛ</span>}
|
||||
</div>
|
||||
<input
|
||||
ref={ref}
|
||||
type="datetime-local"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="opacity-0 w-full h-full absolute inset-0 cursor-pointer"
|
||||
tabIndex={0}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,24 +2,232 @@ import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import client from '../api/client'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
const FILTERS = ['all', 'open', 'partially_paid', 'free']
|
||||
const FILTER_LABELS = { all: 'Όλα', open: 'Ανοιχτά', partially_paid: 'Μερική πληρωμή', free: 'Ελεύθερα' }
|
||||
|
||||
function elapsed(openedAt) {
|
||||
const diff = Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
|
||||
if (diff < 60) return `${diff}λ`
|
||||
return `${Math.floor(diff / 60)}ω ${diff % 60}λ`
|
||||
// ─── Design tokens ────────────────────────────────────────────────────────────
|
||||
const COLORS = {
|
||||
open: {
|
||||
label: 'Ανοιχτό',
|
||||
tint: '#eef7f0', tintStrong: '#d7ecdc',
|
||||
accent: '#2f9e5e', ink: '#1f7042',
|
||||
},
|
||||
partially_paid: {
|
||||
label: 'Μερική πληρ.',
|
||||
tint: '#f4eefb', tintStrong: '#e3d4f3',
|
||||
accent: '#7a44c9', ink: '#57309a',
|
||||
},
|
||||
free: {
|
||||
label: 'Ελεύθερο',
|
||||
tint: '#f4f4f2', tintStrong: '#dfe2e6',
|
||||
accent: '#8a9099', ink: '#5a6169',
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function formatEuro(n) {
|
||||
return '€' + parseFloat(n).toFixed(2)
|
||||
}
|
||||
|
||||
function formatDuration(openedAt) {
|
||||
const mins = Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
|
||||
if (mins < 60) return `${mins}m`
|
||||
const h = Math.floor(mins / 60)
|
||||
const m = mins % 60
|
||||
return m === 0 ? `${h}h` : `${h}h ${m}m`
|
||||
}
|
||||
|
||||
function occupiedMinsFromDate(openedAt) {
|
||||
return Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
|
||||
}
|
||||
|
||||
function orderTotal(items = []) {
|
||||
return items
|
||||
.filter(i => i.status !== 'cancelled')
|
||||
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
.toFixed(2)
|
||||
}
|
||||
|
||||
function avatarColor(name) {
|
||||
const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775']
|
||||
let h = 0
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
|
||||
return palette[h % palette.length]
|
||||
}
|
||||
|
||||
function WaiterBubble({ waiter, size = 26 }) {
|
||||
// waiter: { name, avatarUrl }
|
||||
if (waiter.avatarUrl) {
|
||||
return (
|
||||
<img
|
||||
src={waiter.avatarUrl}
|
||||
alt={waiter.name}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%', objectFit: 'cover',
|
||||
flexShrink: 0, boxShadow: '0 0 0 2px var(--cardBg, white)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const parts = waiter.name.trim().split(' ')
|
||||
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: avatarColor(waiter.name),
|
||||
color: 'white',
|
||||
fontSize: size * 0.42,
|
||||
fontWeight: 600,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 0 0 2px var(--cardBg, white)',
|
||||
}}>{initials}</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── V1 Table Card ────────────────────────────────────────────────────────────
|
||||
function TableCardV1({ name, status, amount, openedAt, waiters = [], onClick }) {
|
||||
const s = COLORS[status] || COLORS.free
|
||||
const [hover, setHover] = useState(false)
|
||||
const [pressed, setPressed] = useState(false)
|
||||
|
||||
const occupiedMins = openedAt ? occupiedMinsFromDate(openedAt) : null
|
||||
const showMulti = waiters.length >= 3
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => { setHover(false); setPressed(false) }}
|
||||
onMouseDown={() => setPressed(true)}
|
||||
onMouseUp={() => setPressed(false)}
|
||||
style={{
|
||||
'--cardBg': s.tint,
|
||||
position: 'relative',
|
||||
width: '100%', minWidth: 330, height: 200,
|
||||
padding: '16px 18px 16px 24px',
|
||||
background: s.tint,
|
||||
border: '1px solid ' + s.tintStrong,
|
||||
borderRadius: 14,
|
||||
boxShadow: pressed
|
||||
? 'inset 0 2px 4px rgba(16,20,24,0.08)'
|
||||
: hover
|
||||
? '0 6px 18px rgba(16,20,24,0.08), 0 2px 4px rgba(16,20,24,0.04)'
|
||||
: '0 1px 2px rgba(16,20,24,0.04), 0 1px 1px rgba(16,20,24,0.03)',
|
||||
transform: pressed ? 'translateY(1px)' : hover ? 'translateY(-2px)' : 'translateY(0)',
|
||||
transition: 'transform 120ms ease, box-shadow 120ms ease',
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
textAlign: 'left',
|
||||
font: 'inherit',
|
||||
color: 'inherit',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
outline: 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* left accent bar */}
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, top: 0, bottom: 0, width: 6,
|
||||
background: s.accent,
|
||||
borderRadius: '14px 0 0 14px',
|
||||
}} />
|
||||
|
||||
{/* Header: name + status pill */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
|
||||
<div style={{
|
||||
fontSize: 34, fontWeight: 700, lineHeight: 1,
|
||||
letterSpacing: -0.5,
|
||||
color: '#111315',
|
||||
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
|
||||
}}>{name}</div>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
height: 26, padding: '0 10px',
|
||||
borderRadius: 999,
|
||||
background: s.accent,
|
||||
color: 'white',
|
||||
fontSize: 12, fontWeight: 600,
|
||||
letterSpacing: 0.2,
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'rgba(255,255,255,0.9)' }} />
|
||||
{s.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flags row — fixed height placeholder */}
|
||||
<div style={{ marginTop: 8, height: 22 }} />
|
||||
|
||||
{/* Stats row */}
|
||||
<div style={{
|
||||
marginTop: 'auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 8,
|
||||
alignItems: 'end',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 600, color: '#111315', marginTop: 2, fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace" }}>
|
||||
{amount != null ? formatEuro(amount) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}>— —</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Time</div>
|
||||
<div style={{
|
||||
fontSize: 22, marginTop: 2,
|
||||
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
|
||||
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
|
||||
color: '#111315',
|
||||
}}>
|
||||
{openedAt ? formatDuration(openedAt) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}>— —</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waiter row */}
|
||||
<div style={{
|
||||
marginTop: 12,
|
||||
paddingTop: 10,
|
||||
borderTop: '1px solid ' + s.tintStrong,
|
||||
height: 36,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
{waiters.length === 0 ? (
|
||||
<span style={{ color: '#8a9099', fontSize: 13 }}>Unassigned</span>
|
||||
) : showMulti ? (
|
||||
<>
|
||||
<div style={{ display: 'flex' }}>
|
||||
{waiters.slice(0, 3).map((w, i) => (
|
||||
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
|
||||
<WaiterBubble waiter={w} size={24} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 13, fontWeight: 600, color: '#2b2f33',
|
||||
background: 'white', border: '1px solid #dfe2e6',
|
||||
borderRadius: 999, padding: '2px 8px',
|
||||
}}>Multiple ({waiters.length})</span>
|
||||
</>
|
||||
) : (
|
||||
waiters.map((w, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<WaiterBubble waiter={w} size={24} />
|
||||
<span style={{ fontSize: 14, color: '#2b2f33', fontWeight: 500 }}>{w.shortName}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
export default function DashboardPage() {
|
||||
const [filter, setFilter] = useState('all')
|
||||
const navigate = useNavigate()
|
||||
@@ -27,13 +235,13 @@ export default function DashboardPage() {
|
||||
const { data: tables = [], isLoading: tablesLoading } = useQuery({
|
||||
queryKey: ['tables'],
|
||||
queryFn: () => client.get('/api/tables/').then(r => r.data),
|
||||
refetchInterval: 30_000,
|
||||
refetchInterval: 5_000,
|
||||
})
|
||||
|
||||
const { data: orders = [], isLoading: ordersLoading } = useQuery({
|
||||
queryKey: ['orders-active'],
|
||||
queryFn: () => client.get('/api/orders/').then(r => r.data),
|
||||
refetchInterval: 30_000,
|
||||
refetchInterval: 5_000,
|
||||
})
|
||||
|
||||
const { data: waiters = [] } = useQuery({
|
||||
@@ -42,9 +250,14 @@ export default function DashboardPage() {
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.username]))
|
||||
// waiterMap: id → { name (display), shortName (nickname or first name), avatarUrl }
|
||||
const waiterMap = Object.fromEntries(waiters.map(w => {
|
||||
const name = w.full_name || w.nickname || w.username
|
||||
const shortName = w.nickname || (w.full_name ? w.full_name.split(' ')[0] : w.username)
|
||||
const avatarUrl = w.avatar_url ? API_URL + w.avatar_url : null
|
||||
return [w.id, { name, shortName, avatarUrl }]
|
||||
}))
|
||||
|
||||
// Build enriched table list
|
||||
const tableCards = tables.map(table => {
|
||||
const order = orders.find(o =>
|
||||
o.table_id === table.id && ['open', 'partially_paid'].includes(o.status)
|
||||
@@ -82,35 +295,25 @@ export default function DashboardPage() {
|
||||
<p className="text-center text-gray-400 py-16">Δεν βρέθηκαν τραπέζια.</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{filtered.map(({ table, order, tableStatus }) => (
|
||||
<button
|
||||
key={table.id}
|
||||
onClick={() => order && navigate(`/orders/${order.id}`)}
|
||||
className={`card p-4 text-left transition-shadow hover:shadow-md ${!order ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<span className="text-2xl font-extrabold text-gray-800">
|
||||
{table.label || `T${table.number}`}
|
||||
</span>
|
||||
<StatusBadge status={tableStatus} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))', gap: 16 }}>
|
||||
{filtered.map(({ table, order, tableStatus }) => {
|
||||
const waiterNames = order
|
||||
? order.waiters.map(w => waiterMap[w.waiter_id] || { name: `#${w.waiter_id}`, shortName: `#${w.waiter_id}`, avatarUrl: null })
|
||||
: []
|
||||
const amount = order ? orderTotal(order.items) : null
|
||||
|
||||
{order ? (
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
<p className="font-semibold text-gray-800">€{orderTotal(order.items)}</p>
|
||||
<p>⏱ {elapsed(order.opened_at)}</p>
|
||||
{order.waiters.length > 0 && (
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{order.waiters.map(w => waiterMap[w.waiter_id] || `#${w.waiter_id}`).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 mt-1">—</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
return (
|
||||
<TableCardV1
|
||||
key={table.id}
|
||||
name={table.label || `T${table.number}`}
|
||||
status={tableStatus}
|
||||
amount={amount}
|
||||
openedAt={order?.opened_at ?? null}
|
||||
waiters={waiterNames}
|
||||
onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,36 @@ import client from '../api/client'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
|
||||
function PrintOrderModal({ onClose, onPrint, printers }) {
|
||||
const [printerId, setPrinterId] = useState(printers[0]?.id ?? '')
|
||||
function submit() {
|
||||
if (!printerId) { toast.error('Επιλέξτε εκτυπωτή'); return }
|
||||
onPrint(Number(printerId))
|
||||
onClose()
|
||||
}
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-gray-800">Εκτύπωση παραγγελίας</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">✕</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Εκτυπωτής</label>
|
||||
<select className="input w-full" value={printerId} onChange={e => setPrinterId(e.target.value)}>
|
||||
<option value="">— Επιλέξτε —</option>
|
||||
{printers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="btn btn-secondary flex-1">Ακύρωση</button>
|
||||
<button onClick={submit} className="btn btn-primary flex-1">Εκτύπωση</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function itemTotal(item) {
|
||||
return (item.unit_price * item.quantity).toFixed(2)
|
||||
}
|
||||
@@ -15,13 +45,58 @@ function formatDate(dt) {
|
||||
return new Date(dt).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' })
|
||||
}
|
||||
|
||||
const EVENT_LABELS = {
|
||||
ORDER_OPENED: 'Άνοιγμα',
|
||||
ITEMS_ADDED: 'Προσθήκη',
|
||||
PAYMENT: 'Πληρωμή',
|
||||
ORDER_CLOSED: 'Κλείσιμο',
|
||||
ORDER_CANCELLED: 'Ακύρωση',
|
||||
ITEM_CANCELLED: 'Ακύρωση αντ.',
|
||||
}
|
||||
|
||||
function AuditTab({ order, waiterMap }) {
|
||||
if (!order.audit_logs || order.audit_logs.length === 0) {
|
||||
return <p className="py-8 text-center text-gray-400 text-sm">Δεν υπάρχουν εγγραφές.</p>
|
||||
}
|
||||
return (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{order.audit_logs.map(log => (
|
||||
<div key={log.id} className="flex items-start gap-3 px-4 py-3">
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
||||
log.event_type === 'PAYMENT' ? 'bg-green-100 text-green-700' :
|
||||
log.event_type.includes('CANCEL') ? 'bg-red-100 text-red-600' :
|
||||
log.event_type === 'ORDER_CLOSED' ? 'bg-gray-100 text-gray-600' :
|
||||
'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{EVENT_LABELS[log.event_type] ?? log.event_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-sm text-gray-700">
|
||||
<span>{log.waiter_name ?? waiterMap[log.waiter_id] ?? `#${log.waiter_id}`}</span>
|
||||
{log.amount != null && (
|
||||
<span className="ml-2 font-semibold text-green-700">€{log.amount.toFixed(2)}</span>
|
||||
)}
|
||||
{log.payment_method && (
|
||||
<span className="ml-1 text-gray-400 text-xs">({log.payment_method})</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 shrink-0">{formatDate(log.created_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OrderDetailPage({ orderId: propOrderId, readOnly = false }) {
|
||||
const { orderId: paramOrderId } = useParams()
|
||||
const orderId = propOrderId ?? paramOrderId
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const [tab, setTab] = useState('overview')
|
||||
const [confirmAction, setConfirmAction] = useState(null) // { type, payload }
|
||||
const [showPrintModal, setShowPrintModal] = useState(false)
|
||||
|
||||
const { data: order, isLoading } = useQuery({
|
||||
queryKey: ['order', orderId],
|
||||
@@ -35,6 +110,18 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const { data: printers = [] } = useQuery({
|
||||
queryKey: ['printers'],
|
||||
queryFn: () => client.get('/api/system/printers').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const printOrder = useMutation({
|
||||
mutationFn: (printerId) => client.post(`/api/orders/${orderId}/print`, { printer_id: printerId }),
|
||||
onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'),
|
||||
onError: () => toast.error('Σφάλμα εκτύπωσης'),
|
||||
})
|
||||
|
||||
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.username]))
|
||||
const assignedIds = new Set((order?.waiters ?? []).map(w => w.waiter_id))
|
||||
|
||||
@@ -119,81 +206,100 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waiters */}
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-3">Σερβιτόροι</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{order.waiters.map(w => (
|
||||
<div key={w.waiter_id} className="flex items-center gap-2 bg-gray-100 rounded-full px-3 py-1">
|
||||
<span className="text-sm">{waiterMap[w.waiter_id] || `#${w.waiter_id}`}</span>
|
||||
{isOpen && !readOnly && (
|
||||
<button
|
||||
onClick={() => removeWaiter.mutate(w.waiter_id)}
|
||||
className="text-gray-400 hover:text-red-500 text-xs leading-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isOpen && !readOnly && (
|
||||
<select
|
||||
className="text-sm border border-gray-300 rounded-full px-3 py-1 focus:outline-none focus:ring-1 focus:ring-primary-600"
|
||||
defaultValue=""
|
||||
onChange={e => { if (e.target.value) assignWaiter.mutate(Number(e.target.value)) }}
|
||||
>
|
||||
<option value="">+ Πρόσθεσε</option>
|
||||
{waiters.filter(w => !assignedIds.has(w.id)).map(w => (
|
||||
<option key={w.id} value={w.id}>{w.username}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div className="px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-gray-700">Αντικείμενα</h2>
|
||||
</div>
|
||||
{order.items.length === 0 && (
|
||||
<p className="px-4 py-6 text-center text-gray-400 text-sm">Κανένα αντικείμενο.</p>
|
||||
)}
|
||||
{order.items.map(item => (
|
||||
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 ${item.status === 'cancelled' ? 'opacity-40 line-through' : ''}`}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</p>
|
||||
{item.notes && <p className="text-xs text-gray-400">{item.notes}</p>}
|
||||
<p className="text-xs text-gray-500">x{item.quantity} · €{item.unit_price.toFixed(2)}/τμχ</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<StatusBadge status={item.status} />
|
||||
<span className="text-sm font-semibold text-gray-700 w-14 text-right">€{itemTotal(item)}</span>
|
||||
{isOpen && !readOnly && item.status === 'active' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => payItems.mutate([item.id])}
|
||||
className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8"
|
||||
>
|
||||
Πληρωμή
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmAction({ type: 'cancelItem', payload: item.id })}
|
||||
className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8"
|
||||
>
|
||||
Ακύρωση
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-gray-200">
|
||||
{[['overview', 'Επισκόπηση'], ['audit', 'Ιστορικό Συναλλαγών']].map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${tab === key ? 'border-primary-600 text-primary-700' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{isOpen && !readOnly && (
|
||||
{tab === 'overview' && <>
|
||||
{/* Waiters */}
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-3">Σερβιτόροι</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{order.waiters.map(w => (
|
||||
<div key={w.waiter_id} className="flex items-center gap-2 bg-gray-100 rounded-full px-3 py-1">
|
||||
<span className="text-sm">{waiterMap[w.waiter_id] || `#${w.waiter_id}`}</span>
|
||||
{isOpen && !readOnly && (
|
||||
<button
|
||||
onClick={() => removeWaiter.mutate(w.waiter_id)}
|
||||
className="text-gray-400 hover:text-red-500 text-xs leading-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isOpen && !readOnly && (
|
||||
<select
|
||||
className="text-sm border border-gray-300 rounded-full px-3 py-1 focus:outline-none focus:ring-1 focus:ring-primary-600"
|
||||
defaultValue=""
|
||||
onChange={e => { if (e.target.value) assignWaiter.mutate(Number(e.target.value)) }}
|
||||
>
|
||||
<option value="">+ Πρόσθεσε</option>
|
||||
{waiters.filter(w => !assignedIds.has(w.id)).map(w => (
|
||||
<option key={w.id} value={w.id}>{w.username}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div className="px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-gray-700">Αντικείμενα</h2>
|
||||
</div>
|
||||
{order.items.length === 0 && (
|
||||
<p className="px-4 py-6 text-center text-gray-400 text-sm">Κανένα αντικείμενο.</p>
|
||||
)}
|
||||
{order.items.map(item => (
|
||||
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 ${item.status === 'cancelled' ? 'opacity-40 line-through' : ''}`}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</p>
|
||||
{item.notes && <p className="text-xs text-gray-400">{item.notes}</p>}
|
||||
<p className="text-xs text-gray-500">x{item.quantity} · €{item.unit_price.toFixed(2)}/τμχ</p>
|
||||
{item.paid_by && (
|
||||
<p className="text-xs text-green-600 mt-0.5">
|
||||
Πληρώθηκε: {waiterMap[item.paid_by] ?? `#${item.paid_by}`}
|
||||
{item.paid_at ? ` · ${formatDate(item.paid_at)}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<StatusBadge status={item.status} />
|
||||
<span className="text-sm font-semibold text-gray-700 w-14 text-right">€{itemTotal(item)}</span>
|
||||
{isOpen && !readOnly && item.status === 'active' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => payItems.mutate([item.id])}
|
||||
className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8"
|
||||
>
|
||||
Πληρωμή
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmAction({ type: 'cancelItem', payload: item.id })}
|
||||
className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8"
|
||||
>
|
||||
Ακύρωση
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{activeItems.length > 0 && (
|
||||
{isOpen && !readOnly && activeItems.length > 0 && (
|
||||
<button
|
||||
onClick={() => payItems.mutate(activeItems.map(i => i.id))}
|
||||
className="btn btn-primary"
|
||||
@@ -201,19 +307,35 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
Πληρωμή όλων
|
||||
</button>
|
||||
)}
|
||||
{isOpen && !readOnly && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setConfirmAction({ type: 'closeOrder' })}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Κλείσιμο παραγγελίας
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmAction({ type: 'cancelOrder' })}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
Ακύρωση παραγγελίας
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setConfirmAction({ type: 'closeOrder' })}
|
||||
onClick={() => setShowPrintModal(true)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Κλείσιμο παραγγελίας
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmAction({ type: 'cancelOrder' })}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
Ακύρωση παραγγελίας
|
||||
🖨 Εκτύπωση
|
||||
</button>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{tab === 'audit' && (
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<AuditTab order={order} waiterMap={waiterMap} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmAction && (
|
||||
@@ -234,6 +356,22 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPrintModal && printers.length > 0 && (
|
||||
<PrintOrderModal
|
||||
printers={printers}
|
||||
onClose={() => setShowPrintModal(false)}
|
||||
onPrint={(printerId) => printOrder.mutate(printerId)}
|
||||
/>
|
||||
)}
|
||||
{showPrintModal && printers.length === 0 && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-2xl p-6 max-w-sm w-full mx-4 space-y-4">
|
||||
<p className="text-gray-700">Δεν βρέθηκαν ενεργοί εκτυπωτές.</p>
|
||||
<button onClick={() => setShowPrintModal(false)} className="btn btn-secondary w-full">Κλείσιμο</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,42 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../api/client'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
function avatarColor(name) {
|
||||
const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775']
|
||||
let h = 0
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
|
||||
return palette[h % palette.length]
|
||||
}
|
||||
|
||||
function WaiterAvatar({ waiter, size = 40 }) {
|
||||
const displayName = waiter.full_name || waiter.nickname || waiter.username
|
||||
if (waiter.avatar_url) {
|
||||
return (
|
||||
<img
|
||||
src={API_URL + waiter.avatar_url}
|
||||
alt={displayName}
|
||||
style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const parts = displayName.trim().split(' ')
|
||||
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: avatarColor(displayName),
|
||||
color: 'white', fontSize: size * 0.38, fontWeight: 600,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>{initials}</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫']
|
||||
|
||||
function PinInput({ value, onChange }) {
|
||||
@@ -30,27 +63,152 @@ function PinInput({ value, onChange }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ZoneModal({ waiter, groups, onClose }) {
|
||||
const qc = useQueryClient()
|
||||
// Derive initial state from waiter's zone_assignments
|
||||
const hasAllZones = waiter.zone_assignments.some(z => z.group_id === null)
|
||||
const assignedIds = new Set(waiter.zone_assignments.map(z => z.group_id).filter(id => id !== null))
|
||||
|
||||
const [allZones, setAllZones] = useState(hasAllZones)
|
||||
const [selected, setSelected] = useState(new Set(assignedIds))
|
||||
|
||||
const saveZones = useMutation({
|
||||
mutationFn: (body) => client.put(`/api/waiters/${waiter.id}/zones`, body),
|
||||
onSuccess: () => { toast.success('Zones ενημερώθηκαν'); qc.invalidateQueries({ queryKey: ['waiters'] }); onClose() },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
|
||||
function toggleGroup(gid) {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(gid)) next.delete(gid); else next.add(gid)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (allZones) {
|
||||
saveZones.mutate({ all_zones: true, group_ids: [] })
|
||||
} else {
|
||||
saveZones.mutate({ all_zones: false, group_ids: [...selected] })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||||
<h2 className="font-bold text-gray-800">Ζώνες — {waiter.username}</h2>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-5 h-5 rounded accent-primary-700"
|
||||
checked={allZones}
|
||||
onChange={e => { setAllZones(e.target.checked); if (e.target.checked) setSelected(new Set()) }}
|
||||
/>
|
||||
<span className="font-semibold text-gray-700">Όλες οι ζώνες</span>
|
||||
</label>
|
||||
|
||||
{!allZones && (
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{groups.length === 0 && (
|
||||
<p className="text-sm text-gray-400">Δεν υπάρχουν ομάδες τραπεζιών.</p>
|
||||
)}
|
||||
{groups.map(g => (
|
||||
<label key={g.id} className="flex items-center gap-3 cursor-pointer select-none px-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-5 h-5 rounded accent-primary-700"
|
||||
checked={selected.has(g.id)}
|
||||
onChange={() => toggleGroup(g.id)}
|
||||
/>
|
||||
<span className="text-gray-700">{g.name}</span>
|
||||
{g.color && (
|
||||
<span className="w-3 h-3 rounded-full inline-block ml-auto" style={{ background: g.color }} />
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!allZones && selected.size === 0 && (
|
||||
<p className="text-xs text-amber-600 bg-amber-50 rounded px-3 py-1.5">
|
||||
Χωρίς επιλογή ο σερβιτόρος δεν βλέπει κανένα τραπέζι.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||
<button onClick={save} disabled={saveZones.isPending} className="flex-1 btn btn-primary">
|
||||
Αποθήκευση
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default function WaitersPage() {
|
||||
const qc = useQueryClient()
|
||||
const [addModal, setAddModal] = useState(false)
|
||||
const [pinModal, setPinModal] = useState(null) // waiter id
|
||||
const [zoneModal, setZoneModal] = useState(null) // waiter object
|
||||
const [confirmDelete, setConfirmDelete] = useState(null) // waiter id
|
||||
const [newPin, setNewPin] = useState('')
|
||||
const [newForm, setNewForm] = useState({ username: '', pin: '', role: 'waiter' })
|
||||
const [newForm, setNewForm] = useState({ username: '', full_name: '', mobile_phone: '', pin: '', role: 'waiter' })
|
||||
const [editModal, setEditModal] = useState(null) // waiter object
|
||||
const [editForm, setEditForm] = useState({ username: '', full_name: '', nickname: '', mobile_phone: '' })
|
||||
const avatarInputRef = useRef(null)
|
||||
|
||||
const { data: waiters = [], isLoading } = useQuery({
|
||||
queryKey: ['waiters'],
|
||||
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: groups = [] } = useQuery({
|
||||
queryKey: ['table-groups'],
|
||||
queryFn: () => client.get('/api/tables/groups').then(r => r.data),
|
||||
})
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['waiters'] })
|
||||
|
||||
const createWaiter = useMutation({
|
||||
mutationFn: (body) => client.post('/api/waiters/', body),
|
||||
onSuccess: () => { toast.success('Σερβιτόρος δημιουργήθηκε'); setAddModal(false); setNewForm({ username: '', pin: '', role: 'waiter' }); invalidate() },
|
||||
onSuccess: () => { toast.success('Σερβιτόρος δημιουργήθηκε'); setAddModal(false); setNewForm({ username: '', full_name: '', mobile_phone: '', pin: '', role: 'waiter' }); invalidate() },
|
||||
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
|
||||
})
|
||||
|
||||
const updateWaiter = useMutation({
|
||||
mutationFn: ({ id, ...body }) => client.put(`/api/waiters/${id}`, body),
|
||||
onSuccess: () => { toast.success('Στοιχεία ενημερώθηκαν'); setEditModal(null); invalidate() },
|
||||
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
|
||||
})
|
||||
|
||||
const uploadAvatar = useMutation({
|
||||
mutationFn: ({ id, file }) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return client.post(`/api/waiters/${id}/avatar`, fd, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
toast.success('Avatar ανέβηκε')
|
||||
setEditModal(res.data)
|
||||
invalidate()
|
||||
},
|
||||
onError: () => toast.error('Σφάλμα μεταφόρτωσης'),
|
||||
})
|
||||
|
||||
const deleteAvatar = useMutation({
|
||||
mutationFn: (id) => client.delete(`/api/waiters/${id}/avatar`),
|
||||
onSuccess: (res) => {
|
||||
toast.success('Avatar αφαιρέθηκε')
|
||||
setEditModal(res.data)
|
||||
invalidate()
|
||||
},
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
|
||||
const toggleBlock = useMutation({
|
||||
mutationFn: (id) => client.put(`/api/waiters/${id}/block`),
|
||||
onSuccess: () => { invalidate() },
|
||||
@@ -72,7 +230,7 @@ export default function WaitersPage() {
|
||||
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-3xl">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-800">Σερβιτόροι</h1>
|
||||
<button onClick={() => setAddModal(true)} className="btn btn-primary">+ Νέος σερβιτόρος</button>
|
||||
@@ -84,13 +242,31 @@ export default function WaitersPage() {
|
||||
)}
|
||||
{waiters.map(w => (
|
||||
<div key={w.id} className="flex items-center gap-4 px-4 py-3">
|
||||
<WaiterAvatar waiter={w} size={44} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-gray-800">{w.username}</p>
|
||||
<p className="text-xs text-gray-500">{w.role}</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="font-semibold text-gray-800">{w.full_name || w.username}</p>
|
||||
{w.nickname && <span className="text-xs text-gray-400">({w.nickname})</span>}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{w.username} · {w.role}</p>
|
||||
{w.mobile_phone && <p className="text-xs text-gray-400">{w.mobile_phone}</p>}
|
||||
{w.role === 'waiter' && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{w.zone_assignments.length === 0
|
||||
? 'Χωρίς ζώνες'
|
||||
: w.zone_assignments.some(z => z.group_id === null)
|
||||
? 'Όλες οι ζώνες'
|
||||
: `${w.zone_assignments.length} ζών${w.zone_assignments.length === 1 ? 'η' : 'ες'}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${w.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}`}>
|
||||
{w.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
|
||||
</span>
|
||||
<button onClick={() => { setEditModal(w); setEditForm({ username: w.username || '', full_name: w.full_name || '', nickname: w.nickname || '', mobile_phone: w.mobile_phone || '' }) }} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επεξεργασία</button>
|
||||
{w.role === 'waiter' && (
|
||||
<button onClick={() => setZoneModal(w)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Ζώνες</button>
|
||||
)}
|
||||
<button onClick={() => setPinModal(w.id)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Reset PIN</button>
|
||||
<button onClick={() => toggleBlock.mutate(w.id)} className={`btn text-sm px-3 py-1.5 min-h-0 h-9 ${w.is_active ? 'btn-danger' : 'btn-secondary'}`}>
|
||||
{w.is_active ? 'Αποκλεισμός' : 'Ενεργοποίηση'}
|
||||
@@ -105,9 +281,17 @@ export default function WaitersPage() {
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||||
<h2 className="font-bold text-gray-800">Νέος σερβιτόρος</h2>
|
||||
<div>
|
||||
<label className="label">Πλήρες όνομα</label>
|
||||
<input className="input" placeholder="π.χ. Γιώργος Παπαδόπουλος" value={newForm.full_name} onChange={e => setNewForm(f => ({ ...f, full_name: e.target.value }))} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Όνομα χρήστη</label>
|
||||
<input className="input" value={newForm.username} onChange={e => setNewForm(f => ({ ...f, username: e.target.value }))} autoFocus />
|
||||
<input className="input" placeholder="π.χ. giorgos" value={newForm.username} onChange={e => setNewForm(f => ({ ...f, username: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Κινητό τηλέφωνο</label>
|
||||
<input className="input" placeholder="π.χ. 6901234567" value={newForm.mobile_phone} onChange={e => setNewForm(f => ({ ...f, mobile_phone: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Ρόλος</label>
|
||||
@@ -123,7 +307,7 @@ export default function WaitersPage() {
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button onClick={() => setAddModal(false)} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||
<button
|
||||
onClick={() => createWaiter.mutate({ username: newForm.username, pin: newForm.pin, role: newForm.role, is_active: true })}
|
||||
onClick={() => createWaiter.mutate({ username: newForm.username, full_name: newForm.full_name || null, mobile_phone: newForm.mobile_phone || null, pin: newForm.pin, role: newForm.role, is_active: true })}
|
||||
disabled={!newForm.username.trim() || newForm.pin.length < 4}
|
||||
className="flex-1 btn btn-primary"
|
||||
>
|
||||
@@ -134,6 +318,76 @@ export default function WaitersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit profile modal */}
|
||||
{editModal && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||||
<h2 className="font-bold text-gray-800">Επεξεργασία — {editModal.username}</h2>
|
||||
|
||||
{/* Avatar section */}
|
||||
<div className="flex items-center gap-4">
|
||||
<WaiterAvatar waiter={editModal} size={64} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) uploadAvatar.mutate({ id: editModal.id, file })
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
disabled={uploadAvatar.isPending}
|
||||
className="btn btn-secondary text-xs px-3 py-1.5 min-h-0 h-8"
|
||||
>
|
||||
{uploadAvatar.isPending ? 'Μεταφόρτωση…' : 'Αλλαγή φωτογραφίας'}
|
||||
</button>
|
||||
{editModal.avatar_url && (
|
||||
<button
|
||||
onClick={() => deleteAvatar.mutate(editModal.id)}
|
||||
disabled={deleteAvatar.isPending}
|
||||
className="btn btn-ghost text-xs px-3 py-1.5 min-h-0 h-8 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
Αφαίρεση
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Όνομα χρήστη</label>
|
||||
<input className="input" value={editForm.username} onChange={e => setEditForm(f => ({ ...f, username: e.target.value }))} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Πλήρες όνομα</label>
|
||||
<input className="input" value={editForm.full_name} onChange={e => setEditForm(f => ({ ...f, full_name: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Παρατσούκλι (nickname)</label>
|
||||
<input className="input" placeholder="π.χ. Γιώργος" value={editForm.nickname} onChange={e => setEditForm(f => ({ ...f, nickname: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Κινητό τηλέφωνο</label>
|
||||
<input className="input" value={editForm.mobile_phone} onChange={e => setEditForm(f => ({ ...f, mobile_phone: e.target.value }))} />
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button onClick={() => setEditModal(null)} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||
<button
|
||||
onClick={() => updateWaiter.mutate({ id: editModal.id, username: editForm.username.trim() || undefined, full_name: editForm.full_name || null, nickname: editForm.nickname || null, mobile_phone: editForm.mobile_phone || null })}
|
||||
disabled={updateWaiter.isPending || !editForm.username.trim()}
|
||||
className="flex-1 btn btn-primary"
|
||||
>
|
||||
Αποθήκευση
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset PIN modal */}
|
||||
{pinModal !== null && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
@@ -164,6 +418,10 @@ export default function WaitersPage() {
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{zoneModal && (
|
||||
<ZoneModal waiter={zoneModal} groups={groups} onClose={() => setZoneModal(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user