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>
826 lines
36 KiB
JavaScript
826 lines
36 KiB
JavaScript
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 (
|
||
<div className="flex flex-wrap gap-2 mt-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => 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 => (
|
||
<button
|
||
key={c}
|
||
type="button"
|
||
onClick={() => onChange(c)}
|
||
className="w-7 h-7 rounded-full border-2 transition-all"
|
||
style={{ background: c, borderColor: value === c ? '#000' : 'transparent' }}
|
||
/>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||
|
||
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 (
|
||
<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={{ 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) && (
|
||
<>
|
||
{/* 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>
|
||
{anySelected ? `${selected.size} επιλεγμένα` : 'Επιλογή'}
|
||
</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" />
|
||
</>
|
||
)}
|
||
|
||
{!selectMode && !anySelected && <span className="flex-1" />}
|
||
|
||
<Button
|
||
variant={showInactive ? 'primary' : 'secondary'}
|
||
size="sm"
|
||
onClick={() => setShowInactive(v => !v)}
|
||
>
|
||
{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>
|
||
|
||
{/* Zone tabs */}
|
||
<div className="flex gap-0 flex-wrap border-b border-slate-200 px-4 flex-shrink-0">
|
||
{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 (
|
||
<div key={tab.id} className="relative flex items-stretch">
|
||
{/* Drop indicator line — left side of this tab */}
|
||
{showLineLeft && (
|
||
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-sky-500 rounded-full z-10 -translate-x-px" />
|
||
)}
|
||
|
||
<button
|
||
onClick={() => { 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 && <span className="text-slate-300 text-xs mr-0.5 select-none">⠿</span>}
|
||
{tab.color && <span className="w-2 h-2 rounded-full shrink-0" style={{ background: tab.color }} />}
|
||
{tab.label}
|
||
<span className="ml-0.5 text-xs text-slate-400">
|
||
({tab.id === 'all' ? tables.length : tab.id === 'ungrouped' ? tables.filter(t => !t.group_id).length : tables.filter(t => t.group_id === tab.id).length})
|
||
</span>
|
||
</button>
|
||
|
||
{/* Drop indicator line — right side of the last group tab */}
|
||
{showLineRight && (
|
||
<div className="absolute right-0 top-1 bottom-1 w-0.5 bg-sky-500 rounded-full z-10 translate-x-px" />
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* 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 (
|
||
<div className="flex items-center gap-3 px-6 py-2 border-b border-slate-100 flex-shrink-0">
|
||
<span className="font-semibold text-slate-700 text-sm">{g.name}</span>
|
||
{g.prefix && <span className="text-xs bg-white text-slate-500 border border-slate-200 px-2 py-0.5 rounded font-mono">{g.prefix}</span>}
|
||
<button onClick={() => setGroupModal(g)} className="text-xs text-slate-400 hover:text-slate-600 underline ml-1">Επεξεργασία</button>
|
||
<Button variant="secondary" size="sm" onClick={() => setBatchModal(g)} className="ml-auto">+ Μαζική προσθήκη</Button>
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
{/* Tables list */}
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
{visibleTables.length === 0 ? (
|
||
<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'} ${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">
|
||
{showCheckbox ? (
|
||
<input
|
||
type="checkbox"
|
||
checked={isSelected}
|
||
onChange={() => toggleSelect(t.id)}
|
||
className="w-4 h-4 rounded accent-sky-500 cursor-pointer"
|
||
onClick={e => e.stopPropagation()}
|
||
/>
|
||
) : (
|
||
<span className="text-xs text-slate-400 font-mono group-hover:hidden">{idx + 1}</span>
|
||
)}
|
||
{!showCheckbox && (
|
||
<input
|
||
type="checkbox"
|
||
checked={false}
|
||
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()}
|
||
/>
|
||
)}
|
||
</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>}
|
||
{!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: true })}
|
||
disabled={t.has_active_order}
|
||
title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
|
||
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 : '', seat_count: null }}
|
||
groups={groups}
|
||
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)}
|
||
/>
|
||
)}
|
||
|
||
{/* Edit table */}
|
||
{editModal && (
|
||
<TableModal
|
||
title="Επεξεργασία τραπεζιού"
|
||
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, seat_count: f.seat_count || null })}
|
||
onClose={() => setEditModal(null)}
|
||
/>
|
||
)}
|
||
|
||
{/* Batch add */}
|
||
{batchModal !== null && (
|
||
<BatchModal
|
||
group={batchModal}
|
||
tables={tables}
|
||
onSave={(body) => batchCreate.mutate(body)}
|
||
onClose={() => setBatchModal(null)}
|
||
/>
|
||
)}
|
||
|
||
{/* 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
|
||
group={groupModal}
|
||
onSave={(data) => saveGroup.mutate(data)}
|
||
onDelete={groupModal.id ? () => deleteGroup.mutate(groupModal.id) : null}
|
||
onClose={() => setGroupModal(null)}
|
||
/>
|
||
)}
|
||
|
||
{/* Delete confirmation */}
|
||
{confirmDelete && (
|
||
<ConfirmModal
|
||
title={
|
||
confirmDelete.type === 'bulk'
|
||
? `Διαγραφή ${confirmDelete.ids.length} τραπεζιών;`
|
||
: confirmDelete.hard ? 'Οριστική διαγραφή τραπεζιού;' : 'Απενεργοποίηση τραπεζιού;'
|
||
}
|
||
message={
|
||
confirmDelete.type === 'bulk'
|
||
? `${confirmDelete.ids.length} τραπέζια θα διαγραφούν οριστικά.`
|
||
: confirmDelete.hard
|
||
? 'Το τραπέζι θα διαγραφεί οριστικά. Αδύνατο αν έχει ενεργή παραγγελία.'
|
||
: 'Το τραπέζι θα κρυφτεί. Μπορείτε να το επανενεργοποιήσετε αργότερα.'
|
||
}
|
||
confirmLabel={confirmDelete.type === 'bulk' || confirmDelete.hard ? 'Διαγραφή' : 'Απενεργοποίηση'}
|
||
confirmVariant="danger"
|
||
onConfirm={async () => {
|
||
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)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<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">{title}</h2>
|
||
<div>
|
||
<label className="label">Όνομα τραπεζιού</label>
|
||
<input
|
||
className="input"
|
||
placeholder="π.χ. BS-1 ή Β3"
|
||
value={form.label}
|
||
maxLength={MAX_TABLE_NAME_LENGTH}
|
||
onChange={e => setForm(f => ({ ...f, label: e.target.value }))}
|
||
autoFocus
|
||
/>
|
||
<div className="flex justify-between mt-1">
|
||
<p className="text-xs text-gray-400">Αφήστε κενό για αυτόματη αρίθμηση.</p>
|
||
{form.label ? (
|
||
<p className={`text-xs font-mono ${labelLen >= MAX_TABLE_NAME_LENGTH ? 'text-red-500' : 'text-gray-400'}`}>
|
||
{labelLen}/{MAX_TABLE_NAME_LENGTH}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
{labelTooLong && (
|
||
<p className="text-xs text-red-500">Το όνομα δεν μπορεί να υπερβαίνει τους {MAX_TABLE_NAME_LENGTH} χαρακτήρες.</p>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<label className="label">Ζώνη</label>
|
||
<select className="input" value={form.group_id} onChange={e => setForm(f => ({ ...f, group_id: e.target.value }))}>
|
||
<option value="">— Χωρίς ζώνη —</option>
|
||
{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
|
||
onClick={() => onSave(form)}
|
||
disabled={labelTooLong}
|
||
className="flex-1 btn btn-primary disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
Αποθήκευση
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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) : []
|
||
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 (
|
||
<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>
|
||
{group && <p className="text-sm text-gray-500">Ζώνη: <span className="font-medium text-gray-700">{group.name}</span></p>}
|
||
<div>
|
||
<label className="label">Πρόθεμα ονόματος</label>
|
||
<input
|
||
className="input"
|
||
placeholder="π.χ. BS- → BS-1, BS-2…"
|
||
value={prefix}
|
||
onChange={e => setPrefix(e.target.value)}
|
||
autoFocus
|
||
/>
|
||
{lengthError ? (
|
||
<p className="text-xs text-red-500 mt-1">{lengthError}</p>
|
||
) : (
|
||
<p className="text-xs text-gray-400 mt-1">Τα ονόματα θα αριθμηθούν αυτόματα συνεχίζοντας από εκεί που σταμάτησαν.</p>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<label className="label">Πλήθος</label>
|
||
<input className="input" type="number" min="1" max="200" value={count} onChange={e => setCount(Number(e.target.value))} />
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||
<button
|
||
onClick={() => 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})` : ''}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<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">{group.id ? 'Επεξεργασία ζώνης' : 'Νέα ζώνη'}</h2>
|
||
<div>
|
||
<label className="label">Όνομα ζώνης *</label>
|
||
<input className="input" value={name} onChange={e => setName(e.target.value)} autoFocus placeholder="π.χ. Beachside" />
|
||
</div>
|
||
<div>
|
||
<label className="label">Πρόθεμα (για μαζική δημιουργία)</label>
|
||
<input className="input font-mono" value={prefix} onChange={e => setPrefix(e.target.value)} placeholder="π.χ. BS" />
|
||
<p className="text-xs text-gray-400 mt-1">Χρησιμοποιείται ως προτεινόμενο πρόθεμα στη μαζική προσθήκη.</p>
|
||
</div>
|
||
<div>
|
||
<label className="label">Χρώμα ζώνης</label>
|
||
<ZoneColorPicker value={color} onChange={setColor} />
|
||
</div>
|
||
<div className="flex gap-3">
|
||
{onDelete && <button onClick={onDelete} className="btn btn-danger px-3">Διαγραφή</button>}
|
||
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||
<button onClick={() => onSave({ name, prefix: prefix || null, color: color || null })} disabled={!name.trim()} className="flex-1 btn btn-primary">Αποθήκευση</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|