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 '../ui/Modal'
import Button from '../ui/Button'
const MAX_TABLE_NAME_LENGTH = 6
const ZONE_COLORS = ['#6366f1','#0ea5e9','#10b981','#f59e0b','#ef4444','#ec4899','#8b5cf6','#14b8a6','#f97316','#64748b']
function ZoneColorPicker({ value, onChange }) {
return (
onChange(null)}
className="w-7 h-7 rounded-full border-2 bg-gray-200 transition-all"
style={{ borderColor: !value ? '#000' : 'transparent' }}
title="Χωρίς χρώμα"
/>
{ZONE_COLORS.map(c => (
onChange(c)}
className="w-7 h-7 rounded-full border-2 transition-all"
style={{ background: c, borderColor: value === c ? '#000' : 'transparent' }}
/>
))}
)
}
export default function TablesPage() {
const qc = useQueryClient()
const [addModal, setAddModal] = useState(false)
const [editModal, setEditModal] = useState(null)
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)
const dragGroupId = useRef(null)
const [dragOverGap, setDragOverGap] = useState(null) // index into groups array (0 = before first)
const { data: tables = [], isLoading } = useQuery({
queryKey: ['tables-all', showInactive],
queryFn: () => client.get(`/api/tables/?include_inactive=${showInactive}`).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: ['tables-all'] })
qc.invalidateQueries({ queryKey: ['tables'] })
}
const invalidateGroups = () => qc.invalidateQueries({ queryKey: ['table-groups'] })
const createTable = useMutation({
mutationFn: (body) => client.post('/api/tables/', body),
onSuccess: () => { toast.success('Τραπέζι δημιουργήθηκε'); setAddModal(false); invalidate() },
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
})
const batchCreate = useMutation({
mutationFn: (body) => client.post('/api/tables/batch', body),
onSuccess: (res) => { toast.success(`${res.data.length} τραπέζια δημιουργήθηκαν`); setBatchModal(null); invalidate() },
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
})
const updateTable = useMutation({
mutationFn: ({ id, ...body }) => client.put(`/api/tables/${id}`, body),
onSuccess: () => { toast.success('Αποθηκεύτηκε'); setEditModal(null); invalidate() },
onError: () => toast.error('Σφάλμα'),
})
const deleteTable = useMutation({
mutationFn: ({ id, hard }) => client.delete(`/api/tables/${id}?hard=${hard}`),
onSuccess: (_, vars) => {
toast.success(vars.hard ? 'Διαγράφηκε' : 'Απενεργοποιήθηκε')
setConfirmDelete(null)
invalidate()
},
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
})
const saveGroup = useMutation({
mutationFn: (body) => groupModal?.id
? client.put(`/api/tables/groups/${groupModal.id}`, body)
: client.post('/api/tables/groups', body),
onSuccess: () => { toast.success('Ζώνη αποθηκεύτηκε'); setGroupModal(null); invalidateGroups(); invalidate() },
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
})
const deleteGroup = useMutation({
mutationFn: (id) => client.delete(`/api/tables/groups/${id}`),
onSuccess: () => { toast.success('Ζώνη διαγράφηκε'); setGroupModal(null); invalidateGroups(); invalidate() },
onError: () => toast.error('Σφάλμα'),
})
const reorderGroups = useMutation({
mutationFn: (ids) => client.put('/api/tables/groups/reorder', ids),
onSuccess: invalidateGroups,
onError: () => toast.error('Σφάλμα αναδιάταξης'),
})
// Filter tables for the active tab
const visibleTables = activeTab === 'all'
? tables
: activeTab === 'ungrouped'
? tables.filter(t => !t.group_id)
: tables.filter(t => t.group_id === activeTab)
const toggleSelect = (id) => setSelected(prev => {
const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n
})
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 Φόρτωση…
const zoneTabs = [
{ id: 'all', label: 'Όλα', color: null },
...groups.map(g => ({ id: g.id, label: g.prefix ? `${g.prefix} – ${g.name}` : g.name, color: g.color, group: g })),
...(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 (
{/* Toolbar */}
{/* Stats — left side */}
{!isLoading && !anySelected && !selectMode && (
{totalTables} τραπέζια
·
{zoneCount} {zoneCount === 1 ? 'ζώνη' : 'ζώνες'}
{inactiveTables > 0 && (
<>
·
{inactiveTables} ανενεργά
>
)}
)}
{/* Multi-select action bar */}
{(selectMode || anySelected) && (
<>
{/* Select-all / deselect-all toggle + close */}
✕
{anySelected ? `${selected.size} επιλεγμένα` : 'Επιλογή'}
{allVisibleSelected ? 'Αποεπιλογή όλων' : 'Επιλογή όλων'}
{anySelected && (
<>
setBulkMoveModal(true)}>
Μετακίνηση ζώνης
setBulkRenameModal(true)}>
Μαζική μετονομασία
Διαγραφή
>
)}
>
)}
{!selectMode && !anySelected &&
}
setShowInactive(v => !v)}
>
{showInactive ? '✓ ' : ''}Ανενεργά
{!selectMode && (
setSelectMode(true)}>
Επιλογή
)}
setGroupModal({})}>+ Νέα ζώνη
setAddModal(true)}>+ Νέο τραπέζι
{/* Zone tabs */}
{zoneTabs.map(tab => {
const isGroup = tab.id !== 'all' && tab.id !== 'ungrouped'
const groupIdx = isGroup ? groups.findIndex(g => g.id === tab.id) : -1
function handleDragOver(e) {
e.preventDefault()
// Use mouse position within the tab to decide left-half vs right-half gap
const rect = e.currentTarget.getBoundingClientRect()
const isLeftHalf = e.clientX < rect.left + rect.width / 2
setDragOverGap(isLeftHalf ? groupIdx : groupIdx + 1)
}
function handleDrop(e) {
e.preventDefault()
const fromId = dragGroupId.current
dragGroupId.current = null
setDragOverGap(null)
if (!fromId) return
const groupIds = groups.map(g => g.id)
const fromIdx = groupIds.indexOf(fromId)
if (fromIdx === -1 || dragOverGap === null) return
const reordered = [...groupIds]
reordered.splice(fromIdx, 1)
// After removing the dragged item, adjust target index
const insertAt = dragOverGap > fromIdx ? dragOverGap - 1 : dragOverGap
reordered.splice(insertAt, 0, fromId)
if (reordered.join() !== groupIds.join()) reorderGroups.mutate(reordered)
}
const showLineLeft = isGroup && dragOverGap === groupIdx
const showLineRight = isGroup && dragOverGap === groupIdx + 1 && groupIdx === groups.length - 1
return (
{/* Drop indicator line — left side of this tab */}
{showLineLeft && (
)}
{ setActiveTab(tab.id); clearSelect() }}
draggable={isGroup}
onDragStart={isGroup ? () => { dragGroupId.current = tab.id } : undefined}
onDragOver={isGroup ? handleDragOver : undefined}
onDragLeave={isGroup ? () => setDragOverGap(null) : undefined}
onDrop={isGroup ? handleDrop : undefined}
onDragEnd={isGroup ? () => { dragGroupId.current = null; setDragOverGap(null) } : undefined}
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === tab.id
? 'border-sky-500 text-sky-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
} ${isGroup ? 'cursor-grab active:cursor-grabbing' : ''}`}
>
{isGroup && ⠿ }
{tab.color && }
{tab.label}
({tab.id === 'all' ? tables.length : tab.id === 'ungrouped' ? tables.filter(t => !t.group_id).length : tables.filter(t => t.group_id === tab.id).length})
{/* Drop indicator line — right side of the last group tab */}
{showLineRight && (
)}
)
})}
{/* Zone action bar (when viewing a specific zone) */}
{activeTab !== 'all' && activeTab !== 'ungrouped' && (() => {
const g = groups.find(g => g.id === activeTab)
if (!g) return null
return (
{g.name}
{g.prefix && {g.prefix} }
setGroupModal(g)} className="text-xs text-slate-400 hover:text-slate-600 underline ml-1">Επεξεργασία
setBatchModal(g)} className="ml-auto">+ Μαζική προσθήκη
)
})()}
{/* Tables list */}
{visibleTables.length === 0 ? (
{showInactive ? 'Δεν υπάρχουν τραπέζια.' : 'Δεν υπάρχουν ενεργά τραπέζια.'}
) : (() => {
// 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 (
{visibleTables.map((t, idx) => {
const isSelected = selected.has(t.id)
const isDuplicate = t.label && duplicateLabels.has(t.label)
const showCheckbox = selectMode || anySelected || isSelected
return (
toggleSelect(t.id) : undefined}
onMouseEnter={() => setAnyHovered(true)}
onMouseLeave={() => setAnyHovered(false)}
>
{/* Number / Checkbox */}
{showCheckbox ? (
toggleSelect(t.id)}
className="w-4 h-4 rounded accent-sky-500 cursor-pointer"
onClick={e => e.stopPropagation()}
/>
) : (
{idx + 1}
)}
{!showCheckbox && (
{ setSelectMode(true); toggleSelect(t.id) }}
className="hidden group-hover:block w-4 h-4 rounded accent-sky-500 cursor-pointer"
onClick={e => e.stopPropagation()}
/>
)}
{t.label || `Τραπέζι ${t.number}`}
{isDuplicate && (
Διπλό όνομα
)}
{t.seat_count != null && (
{t.seat_count} θέσ.
)}
{t.group && (
{t.group.name}
)}
{!t.is_active &&
Ανενεργό }
{!selectMode && (
<>
setEditModal(t)}>Επεξεργασία
{t.is_active
?
!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"
>Απενεργ.
:
updateTable.mutate({ id: t.id, is_active: true })}
className="text-green-600 hover:bg-green-50"
>Ενεργοπ.
}
!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"
>Διαγραφή
>
)}
)
})}
)
})()}
{/* Add single table */}
{addModal && (
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)}
/>
)}
{/* Edit table */}
{editModal && (
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)}
/>
)}
{/* Batch add */}
{batchModal !== null && (
batchCreate.mutate(body)}
onClose={() => setBatchModal(null)}
/>
)}
{/* Bulk move zone */}
{bulkMoveModal && (
setBulkMoveModal(false)}
/>
)}
{/* Bulk rename */}
{bulkRenameModal && (
selected.has(t.id))}
onSave={handleBulkRename}
onClose={() => setBulkRenameModal(false)}
/>
)}
{/* Group/Zone form */}
{groupModal !== null && (
saveGroup.mutate(data)}
onDelete={groupModal.id ? () => deleteGroup.mutate(groupModal.id) : null}
onClose={() => setGroupModal(null)}
/>
)}
{/* Delete confirmation */}
{confirmDelete && (
{
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); exitSelectMode(); invalidate()
} else {
deleteTable.mutate({ id: confirmDelete.id, hard: confirmDelete.hard })
}
}}
onCancel={() => setConfirmDelete(null)}
/>
)}
)
}
function TableModal({ title, initial, groups, onSave, onClose }) {
const [form, setForm] = useState(initial)
const labelLen = (form.label || '').length
const labelTooLong = labelLen > MAX_TABLE_NAME_LENGTH
return (
{title}
Όνομα τραπεζιού
setForm(f => ({ ...f, label: e.target.value }))}
autoFocus
/>
Αφήστε κενό για αυτόματη αρίθμηση.
{form.label ? (
= MAX_TABLE_NAME_LENGTH ? 'text-red-500' : 'text-gray-400'}`}>
{labelLen}/{MAX_TABLE_NAME_LENGTH}
) : null}
{labelTooLong && (
Το όνομα δεν μπορεί να υπερβαίνει τους {MAX_TABLE_NAME_LENGTH} χαρακτήρες.
)}
Ζώνη
setForm(f => ({ ...f, group_id: e.target.value }))}>
— Χωρίς ζώνη —
{groups.map(g => {g.name}{g.prefix ? ` (${g.prefix})` : ''} )}
Ακύρωση
onSave(form)}
disabled={labelTooLong}
className="flex-1 btn btn-primary disabled:opacity-40 disabled:cursor-not-allowed"
>
Αποθήκευση
)
}
function BulkMoveModal({ count, groups, onSave, onClose }) {
const [groupId, setGroupId] = useState('')
return (
Μετακίνηση σε ζώνη
{count} {count === 1 ? 'τραπέζι' : 'τραπέζια'} επιλεγμένα.
Νέα ζώνη
setGroupId(e.target.value)} autoFocus>
— Χωρίς ζώνη —
{groups.map(g => (
{g.name}{g.prefix ? ` (${g.prefix})` : ''}
))}
Ακύρωση
onSave(groupId ? Number(groupId) : null)}
className="flex-1 btn btn-primary"
>
Μετακίνηση
)
}
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 (
Μαζική μετονομασία
{count} {count === 1 ? 'τραπέζι' : 'τραπέζια'} επιλεγμένα — θα μετονομαστούν με τη σειρά που εμφανίζονται στη λίστα.
Πρόθεμα
setPrefix(e.target.value)}
autoFocus
/>
Αριθμός εκκίνησης
setStartNumber(Math.max(1, Number(e.target.value) || 1))}
/>
{/* Live preview */}
{trimmedPrefix && !lengthError && (
Προεπισκόπηση:
{previewNames.map((name, i) => (
{name}
))}
{hasMore && (
… +{count - 5} ακόμα
)}
)}
{lengthError && (
{lengthError}
)}
Ακύρωση
onSave(trimmedPrefix, startNumber)}
disabled={!trimmedPrefix || !!lengthError}
className="flex-1 btn btn-primary disabled:opacity-40 disabled:cursor-not-allowed"
>
Μετονομασία {count} τραπεζιών
)
}
function computeStartNumber(tables, groupId, prefix) {
if (!prefix) return 1
const inGroup = groupId ? tables.filter(t => t.group_id === groupId) : []
const used = inGroup
.filter(t => t.label && t.label.startsWith(prefix))
.map(t => {
const suffix = t.label.slice(prefix.length)
return /^\d+$/.test(suffix) ? parseInt(suffix, 10) : null
})
.filter(n => n !== null)
return used.length > 0 ? Math.max(...used) + 1 : 1
}
function BatchModal({ group, tables, onSave, onClose }) {
const [count, setCount] = useState(5)
const [prefix, setPrefix] = useState(group?.prefix ? `${group.prefix}-` : '')
const trimmedPrefix = prefix.trim()
const startNumber = computeStartNumber(tables, group?.id ?? null, trimmedPrefix)
const lastN = startNumber + count - 1
const worstCase = `${trimmedPrefix}${lastN}`
const lengthError = trimmedPrefix && worstCase.length > MAX_TABLE_NAME_LENGTH
? `O τελευταίος πίνακας θα ονομαστεί '${worstCase}' (${worstCase.length} χαρ.). Μικρύνετε το πρόθεμα ή μειώστε τον αριθμό.`
: null
return (
Μαζική προσθήκη τραπεζιών
{group &&
Ζώνη: {group.name}
}
Πρόθεμα ονόματος
setPrefix(e.target.value)}
autoFocus
/>
{lengthError ? (
{lengthError}
) : (
Τα ονόματα θα αριθμηθούν αυτόματα συνεχίζοντας από εκεί που σταμάτησαν.
)}
Πλήθος
setCount(Number(e.target.value))} />
Ακύρωση
onSave({ group_id: group?.id ?? null, count, name_prefix: trimmedPrefix })}
disabled={count < 1 || !trimmedPrefix || !!lengthError}
className="flex-1 btn btn-primary disabled:opacity-40 disabled:cursor-not-allowed"
>
Δημιουργία {count > 0 && trimmedPrefix && !lengthError ? `(${trimmedPrefix}${startNumber} … ${trimmedPrefix}${lastN})` : ''}
)
}
function GroupModal({ group, onSave, onDelete, onClose }) {
const [name, setName] = useState(group.name || '')
const [prefix, setPrefix] = useState(group.prefix || '')
const [color, setColor] = useState(group.color || null)
return (
{group.id ? 'Επεξεργασία ζώνης' : 'Νέα ζώνη'}
Όνομα ζώνης *
setName(e.target.value)} autoFocus placeholder="π.χ. Beachside" />
Χρώμα ζώνης
{onDelete && Διαγραφή }
Ακύρωση
onSave({ name, prefix: prefix || null, color: color || null })} disabled={!name.trim()} className="flex-1 btn btn-primary">Αποθήκευση
)
}