Files
xenia-pos-local/manager_dashboard/src/pages/TablesConfigTab.jsx
bonamin 34ae328b0d 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>
2026-07-19 10:00:14 +03:00

826 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}