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 (
) } 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 && ( <> )} )} {!selectMode && !anySelected && } {!selectMode && ( )}
{/* 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 && (
)} {/* 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}}
) })()} {/* 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 && ( <> {t.is_active ? : } )}
) })}
) })()}
{/* 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, seat_count: e.target.value ? Number(e.target.value) : null }))} />

Αφήστε κενό αν δεν θέλετε να ορίσετε θέσεις.

) } function BulkMoveModal({ count, groups, onSave, onClose }) { const [groupId, setGroupId] = useState('') return (

Μετακίνηση σε ζώνη

{count} {count === 1 ? 'τραπέζι' : 'τραπέζια'} επιλεγμένα.

) } 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}

)}
) } 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))} />
) } 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" />
setPrefix(e.target.value)} placeholder="π.χ. BS" />

Χρησιμοποιείται ως προτεινόμενο πρόθεμα στη μαζική προσθήκη.

{onDelete && }
) }