feat: bump client-services (accumulated feature work + deploy fixes)

Snapshot of in-progress work across local_backend, manager_dashboard,
and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM,
inventory, permissions), plus the nginx/docker-compose deploy fixes for
the Unraid + NPM reverse-proxy setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 10:00:14 +03:00
parent 02ec1aa28f
commit 34ae328b0d
182 changed files with 34874 additions and 3556 deletions

View File

@@ -39,7 +39,10 @@ export default function TablesPage() {
const [batchModal, setBatchModal] = useState(null) // group object or null
const [groupModal, setGroupModal] = useState(null) // null | {} | group object
const [confirmDelete, setConfirmDelete] = useState(null)
const [bulkMoveModal, setBulkMoveModal] = useState(false)
const [bulkRenameModal, setBulkRenameModal] = useState(false)
const [showInactive, setShowInactive] = useState(false)
const [selectMode, setSelectMode] = useState(false)
const [activeTab, setActiveTab] = useState('all') // 'all' | group.id
const [selected, setSelected] = useState(new Set())
const [anyHovered, setAnyHovered] = useState(false)
@@ -122,11 +125,59 @@ export default function TablesPage() {
})
const clearSelect = () => setSelected(new Set())
const anySelected = selected.size > 0
const allVisibleSelected = visibleTables.length > 0 && visibleTables.every(t => selected.has(t.id))
function toggleSelectAll() {
if (allVisibleSelected) {
// Deselect all visible
setSelected(prev => {
const n = new Set(prev)
visibleTables.forEach(t => n.delete(t.id))
return n
})
} else {
// Select all visible
setSelected(prev => {
const n = new Set(prev)
visibleTables.forEach(t => n.add(t.id))
return n
})
}
}
function exitSelectMode() {
setSelectMode(false)
clearSelect()
}
function bulkDelete() {
setConfirmDelete({ type: 'bulk', ids: [...selected] })
}
async function handleBulkMove(groupId) {
const ids = [...selected]
await Promise.allSettled(ids.map(id => client.put(`/api/tables/${id}`, { group_id: groupId })))
toast.success(`${ids.length} τραπέζια μετακινήθηκαν`)
setBulkMoveModal(false)
exitSelectMode()
invalidate()
}
async function handleBulkRename(prefix, startNumber) {
const ids = [...selected]
// Apply names in the order tables appear in the visible list
const orderedIds = visibleTables.filter(t => selected.has(t.id)).map(t => t.id)
const patches = orderedIds.map((id, i) => ({
id,
label: `${prefix}${startNumber + i}`,
}))
await Promise.allSettled(patches.map(({ id, label }) => client.put(`/api/tables/${id}`, { label })))
toast.success(`${orderedIds.length} τραπέζια μετονομάστηκαν`)
setBulkRenameModal(false)
exitSelectMode()
invalidate()
}
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση</div>
const zoneTabs = [
@@ -135,22 +186,66 @@ export default function TablesPage() {
...(tables.some(t => !t.group_id) ? [{ id: 'ungrouped', label: 'Χωρίς ζώνη', color: null }] : []),
]
const totalTables = tables.length
const inactiveTables = tables.filter(t => !t.is_active).length
const zoneCount = groups.length
return (
<div className="flex flex-col h-full min-h-0">
{/* Toolbar */}
<div className="flex gap-2 flex-wrap items-center border-b border-slate-200 px-6 flex-shrink-0" style={{ height: 60 }}>
{anySelected ? (
<div className="flex gap-2 flex-wrap items-center border-b border-slate-200 px-6 flex-shrink-0" style={{ minHeight: 60 }}>
{/* Stats — left side */}
{!isLoading && !anySelected && !selectMode && (
<div className="flex items-center gap-3 text-[12px] text-slate-400 mr-2">
<span><strong className="text-slate-700 font-semibold">{totalTables}</strong> τραπέζια</span>
<span className="text-slate-200">·</span>
<span><strong className="text-slate-700 font-semibold">{zoneCount}</strong> {zoneCount === 1 ? 'ζώνη' : 'ζώνες'}</span>
{inactiveTables > 0 && (
<>
<span className="text-slate-200">·</span>
<span><strong className="text-amber-600 font-semibold">{inactiveTables}</strong> ανενεργά</span>
</>
)}
</div>
)}
{/* Multi-select action bar */}
{(selectMode || anySelected) && (
<>
<button onClick={clearSelect} className="text-xs text-slate-500 hover:text-slate-700 flex items-center gap-1.5 mr-1">
{/* Select-all / deselect-all toggle + close */}
<button
onClick={exitSelectMode}
className="text-xs text-slate-500 hover:text-slate-700 flex items-center gap-1.5 mr-1"
>
<span className="w-4 h-4 rounded border border-slate-300 flex items-center justify-center text-[10px]"></span>
{selected.size} επιλεγμένα
{anySelected ? `${selected.size} επιλεγμένα` : 'Επιλογή'}
</button>
<Button variant="danger" size="sm" onClick={bulkDelete}>Διαγραφή επιλεγμένων</Button>
<button
onClick={toggleSelectAll}
className="text-xs text-sky-600 hover:text-sky-800 underline"
>
{allVisibleSelected ? 'Αποεπιλογή όλων' : 'Επιλογή όλων'}
</button>
{anySelected && (
<>
<span className="w-px h-4 bg-slate-200 mx-1" />
<Button variant="secondary" size="sm" onClick={() => setBulkMoveModal(true)}>
Μετακίνηση ζώνης
</Button>
<Button variant="secondary" size="sm" onClick={() => setBulkRenameModal(true)}>
Μαζική μετονομασία
</Button>
<Button variant="danger" size="sm" onClick={bulkDelete}>
Διαγραφή
</Button>
</>
)}
<span className="flex-1" />
</>
) : (
<span className="flex-1" />
)}
{!selectMode && !anySelected && <span className="flex-1" />}
<Button
variant={showInactive ? 'primary' : 'secondary'}
size="sm"
@@ -158,6 +253,11 @@ export default function TablesPage() {
>
{showInactive ? '✓ ' : ''}Ανενεργά
</Button>
{!selectMode && (
<Button variant="secondary" size="sm" onClick={() => setSelectMode(true)}>
Επιλογή
</Button>
)}
<Button variant="secondary" size="sm" onClick={() => setGroupModal({})}>+ Νέα ζώνη</Button>
<Button variant="primary" size="sm" onClick={() => setAddModal(true)}>+ Νέο τραπέζι</Button>
</div>
@@ -254,20 +354,29 @@ export default function TablesPage() {
<p className="py-10 text-sm text-slate-400 text-center">
{showInactive ? 'Δεν υπάρχουν τραπέζια.' : 'Δεν υπάρχουν ενεργά τραπέζια.'}
</p>
) : (
) : (() => {
// Build a set of labels that appear more than once across ALL tables (not just visible)
const labelCounts = {}
tables.forEach(t => { if (t.label) labelCounts[t.label] = (labelCounts[t.label] || 0) + 1 })
const duplicateLabels = new Set(Object.keys(labelCounts).filter(l => labelCounts[l] > 1))
return (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden divide-y divide-slate-100">
{visibleTables.map((t, idx) => {
const isSelected = selected.has(t.id)
const isDuplicate = t.label && duplicateLabels.has(t.label)
const showCheckbox = selectMode || anySelected || isSelected
return (
<div
key={t.id}
className={`flex items-center gap-4 px-4 py-3 group transition-colors ${!t.is_active ? 'opacity-50' : ''} ${isSelected ? 'bg-sky-50' : 'hover:bg-slate-50'}`}
className={`flex items-center gap-4 px-4 py-3 group transition-colors ${!t.is_active ? 'opacity-50' : ''} ${isSelected ? 'bg-sky-50' : 'hover:bg-slate-50'} ${selectMode ? 'cursor-pointer' : ''}`}
onClick={selectMode ? () => toggleSelect(t.id) : undefined}
onMouseEnter={() => setAnyHovered(true)}
onMouseLeave={() => setAnyHovered(false)}
>
{/* Number / Checkbox */}
<div className="w-6 shrink-0 flex items-center justify-center">
{anySelected || isSelected ? (
{showCheckbox ? (
<input
type="checkbox"
checked={isSelected}
@@ -276,15 +385,13 @@ export default function TablesPage() {
onClick={e => e.stopPropagation()}
/>
) : (
<span
className="text-xs text-slate-400 font-mono group-hover:hidden"
>{idx + 1}</span>
<span className="text-xs text-slate-400 font-mono group-hover:hidden">{idx + 1}</span>
)}
{!anySelected && !isSelected && (
{!showCheckbox && (
<input
type="checkbox"
checked={false}
onChange={() => toggleSelect(t.id)}
onChange={() => { setSelectMode(true); toggleSelect(t.id) }}
className="hidden group-hover:block w-4 h-4 rounded accent-sky-500 cursor-pointer"
onClick={e => e.stopPropagation()}
/>
@@ -292,51 +399,66 @@ export default function TablesPage() {
</div>
<p className="flex-1 font-medium text-slate-800">{t.label || `Τραπέζι ${t.number}`}</p>
{isDuplicate && (
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 border border-amber-200">
Διπλό όνομα
</span>
)}
{t.seat_count != null && (
<span className="text-xs text-slate-400 hidden sm:inline" title="Αριθμός θέσεων">
{t.seat_count} θέσ.
</span>
)}
{t.group && (
<span className="text-xs bg-slate-100 text-slate-500 px-2 py-0.5 rounded hidden sm:inline">
{t.group.name}
</span>
)}
{!t.is_active && <span className="text-xs text-amber-600 font-medium">Ανενεργό</span>}
<Button variant="secondary" size="sm" onClick={() => setEditModal(t)}>Επεξεργασία</Button>
{t.is_active
? <Button
variant="ghost"
{!selectMode && (
<>
<Button variant="secondary" size="sm" onClick={() => setEditModal(t)}>Επεξεργασία</Button>
{t.is_active
? <Button
variant="ghost"
size="sm"
onClick={() => !t.has_active_order && setConfirmDelete({ type: 'single', id: t.id, hard: false })}
disabled={t.has_active_order}
title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
className="text-amber-600 hover:bg-amber-50 disabled:opacity-40 disabled:cursor-not-allowed"
>Απενεργ.</Button>
: <Button
variant="ghost"
size="sm"
onClick={() => updateTable.mutate({ id: t.id, is_active: true })}
className="text-green-600 hover:bg-green-50"
>Ενεργοπ.</Button>
}
<Button
variant="danger"
size="sm"
onClick={() => !t.has_active_order && setConfirmDelete({ type: 'single', id: t.id, hard: false })}
onClick={() => !t.has_active_order && setConfirmDelete({ type: 'single', id: t.id, hard: true })}
disabled={t.has_active_order}
title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
className="text-amber-600 hover:bg-amber-50 disabled:opacity-40 disabled:cursor-not-allowed"
>Απενεργ.</Button>
: <Button
variant="ghost"
size="sm"
onClick={() => updateTable.mutate({ id: t.id, is_active: true })}
className="text-green-600 hover:bg-green-50"
>Ενεργοπ.</Button>
}
<Button
variant="danger"
size="sm"
onClick={() => !t.has_active_order && setConfirmDelete({ type: 'single', id: t.id, hard: true })}
disabled={t.has_active_order}
title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
className="disabled:opacity-40 disabled:cursor-not-allowed"
>Διαγραφή</Button>
className="disabled:opacity-40 disabled:cursor-not-allowed"
>Διαγραφή</Button>
</>
)}
</div>
)
})}
</div>
)}
)
})()}
</div>
{/* Add single table */}
{addModal && (
<TableModal
title="Νέο τραπέζι"
initial={{ label: '', group_id: activeTab !== 'all' && activeTab !== 'ungrouped' ? activeTab : '' }}
initial={{ label: '', group_id: activeTab !== 'all' && activeTab !== 'ungrouped' ? activeTab : '', seat_count: null }}
groups={groups}
onSave={(f) => createTable.mutate({ label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
onSave={(f) => createTable.mutate({ label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null, seat_count: f.seat_count || null })}
onClose={() => setAddModal(false)}
/>
)}
@@ -345,9 +467,9 @@ export default function TablesPage() {
{editModal && (
<TableModal
title="Επεξεργασία τραπεζιού"
initial={{ label: editModal.label || '', group_id: editModal.group_id || '' }}
initial={{ label: editModal.label || '', group_id: editModal.group_id || '', seat_count: editModal.seat_count ?? null }}
groups={groups}
onSave={(f) => updateTable.mutate({ id: editModal.id, label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
onSave={(f) => updateTable.mutate({ id: editModal.id, label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null, seat_count: f.seat_count || null })}
onClose={() => setEditModal(null)}
/>
)}
@@ -362,6 +484,25 @@ export default function TablesPage() {
/>
)}
{/* Bulk move zone */}
{bulkMoveModal && (
<BulkMoveModal
count={selected.size}
groups={groups}
onSave={handleBulkMove}
onClose={() => setBulkMoveModal(false)}
/>
)}
{/* Bulk rename */}
{bulkRenameModal && (
<BulkRenameModal
selectedTables={visibleTables.filter(t => selected.has(t.id))}
onSave={handleBulkRename}
onClose={() => setBulkRenameModal(false)}
/>
)}
{/* Group/Zone form */}
{groupModal !== null && (
<GroupModal
@@ -393,7 +534,7 @@ export default function TablesPage() {
if (confirmDelete.type === 'bulk') {
await Promise.allSettled(confirmDelete.ids.map(id => client.delete(`/api/tables/${id}?hard=true`)))
toast.success(`${confirmDelete.ids.length} τραπέζια διαγράφηκαν`)
setConfirmDelete(null); clearSelect(); invalidate()
setConfirmDelete(null); exitSelectMode(); invalidate()
} else {
deleteTable.mutate({ id: confirmDelete.id, hard: confirmDelete.hard })
}
@@ -443,6 +584,19 @@ function TableModal({ title, initial, groups, onSave, onClose }) {
{groups.map(g => <option key={g.id} value={g.id}>{g.name}{g.prefix ? ` (${g.prefix})` : ''}</option>)}
</select>
</div>
<div>
<label className="label">Αριθμός θέσεων</label>
<input
className="input"
type="number"
min="1"
max="99"
placeholder="π.χ. 4"
value={form.seat_count ?? ''}
onChange={e => setForm(f => ({ ...f, seat_count: e.target.value ? Number(e.target.value) : null }))}
/>
<p className="text-xs text-gray-400 mt-1">Αφήστε κενό αν δεν θέλετε να ορίσετε θέσεις.</p>
</div>
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button
@@ -458,6 +612,123 @@ function TableModal({ title, initial, groups, onSave, onClose }) {
)
}
function BulkMoveModal({ count, groups, onSave, onClose }) {
const [groupId, setGroupId] = useState('')
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">Μετακίνηση σε ζώνη</h2>
<p className="text-sm text-slate-500">{count} {count === 1 ? 'τραπέζι' : 'τραπέζια'} επιλεγμένα.</p>
<div>
<label className="label">Νέα ζώνη</label>
<select className="input" value={groupId} onChange={e => setGroupId(e.target.value)} autoFocus>
<option value=""> Χωρίς ζώνη </option>
{groups.map(g => (
<option key={g.id} value={g.id}>{g.name}{g.prefix ? ` (${g.prefix})` : ''}</option>
))}
</select>
</div>
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button
onClick={() => onSave(groupId ? Number(groupId) : null)}
className="flex-1 btn btn-primary"
>
Μετακίνηση
</button>
</div>
</div>
</div>
)
}
function BulkRenameModal({ selectedTables, onSave, onClose }) {
const count = selectedTables.length
const [prefix, setPrefix] = useState('')
const [startNumber, setStartNumber] = useState(1)
const trimmedPrefix = prefix.trim()
const lastN = startNumber + count - 1
const worstCase = `${trimmedPrefix}${lastN}`
const lengthError = trimmedPrefix && worstCase.length > MAX_TABLE_NAME_LENGTH
? `Το τελευταίο όνομα θα είναι '${worstCase}' (${worstCase.length} χαρ.). Μικρύνετε το πρόθεμα ή αλλάξτε αριθμό εκκίνησης.`
: null
// Live preview — at most 5 names shown
const previewNames = Array.from({ length: Math.min(count, 5) }, (_, i) =>
trimmedPrefix ? `${trimmedPrefix}${startNumber + i}` : null
)
const hasMore = count > 5
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">Μαζική μετονομασία</h2>
<p className="text-sm text-slate-500">
{count} {count === 1 ? 'τραπέζι' : 'τραπέζια'} επιλεγμένα θα μετονομαστούν με τη σειρά που εμφανίζονται στη λίστα.
</p>
<div>
<label className="label">Πρόθεμα</label>
<input
className="input font-mono"
placeholder="π.χ. BS-"
value={prefix}
maxLength={MAX_TABLE_NAME_LENGTH - 1}
onChange={e => setPrefix(e.target.value)}
autoFocus
/>
</div>
<div>
<label className="label">Αριθμός εκκίνησης</label>
<input
className="input"
type="number"
min="1"
max="999"
value={startNumber}
onChange={e => setStartNumber(Math.max(1, Number(e.target.value) || 1))}
/>
</div>
{/* Live preview */}
{trimmedPrefix && !lengthError && (
<div className="bg-slate-50 rounded-lg px-3 py-2 text-xs text-slate-500 space-y-1">
<p className="font-medium text-slate-600 mb-1">Προεπισκόπηση:</p>
<div className="flex flex-wrap gap-1.5">
{previewNames.map((name, i) => (
<span key={i} className="bg-white border border-slate-200 rounded px-2 py-0.5 font-mono text-slate-700">
{name}
</span>
))}
{hasMore && (
<span className="text-slate-400 self-center"> +{count - 5} ακόμα</span>
)}
</div>
</div>
)}
{lengthError && (
<p className="text-xs text-red-500">{lengthError}</p>
)}
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button
onClick={() => onSave(trimmedPrefix, startNumber)}
disabled={!trimmedPrefix || !!lengthError}
className="flex-1 btn btn-primary disabled:opacity-40 disabled:cursor-not-allowed"
>
Μετονομασία {count} τραπεζιών
</button>
</div>
</div>
</div>
)
}
function computeStartNumber(tables, groupId, prefix) {
if (!prefix) return 1
const inGroup = groupId ? tables.filter(t => t.group_id === groupId) : []