Waiter PWA: sub-choices in modal, zone filter, login JSON fix
ItemOptionsModal:
- Supports inline sub-choices on both preference choices and checkbox options;
sub-choices appear indented when the parent is selected
- Supports shared_subset at the preference-set level (shown unless the selected
choice has disables_subset)
- Pre-selects default choices and their default sub-choices on open
- Add button disabled + red validation hints until all required selections made
- Price total reflects sub-choice extra_cost
TableListPage:
- Zone filter dropdown with multi-select; filters by table group_id
(fetches /api/tables/groups alongside tables and orders)
- Fixed 'mine' and 'free' filters to compose correctly with zone filter
LoginPage:
- Switch to JSON body { username, pin } to match updated /api/auth/login
- Read user fields from data.user.* instead of flat response
vite.config.js: enable SW devOptions so PWA works in dev mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,40 +10,164 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
|
|||||||
const ingredients = product.ingredients || []
|
const ingredients = product.ingredients || []
|
||||||
const preferenceSets = product.preference_sets || []
|
const preferenceSets = product.preference_sets || []
|
||||||
|
|
||||||
const [selectedPreferences, setSelectedPreferences] = useState(
|
// selectedPreferences: { [setId]: choice | null }
|
||||||
Object.fromEntries(preferenceSets.map(ps => [ps.id, null]))
|
const [selectedPreferences, setSelectedPreferences] = useState(() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
preferenceSets.map(ps => {
|
||||||
|
const def = ps.default_choice_id != null
|
||||||
|
? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
|
||||||
|
: null
|
||||||
|
return [ps.id, def]
|
||||||
|
})
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Per-preference-choice inline sub-choices: { [choiceId]: subChoice | null }
|
||||||
|
const [selectedSubChoices, setSelectedSubChoices] = useState(() => {
|
||||||
|
const init = {}
|
||||||
|
preferenceSets.forEach(ps => {
|
||||||
|
const def = ps.default_choice_id != null
|
||||||
|
? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
|
||||||
|
: null
|
||||||
|
if (def && def.sub_choices?.length > 0) {
|
||||||
|
const subDef = def.sub_choices.find(s => s.is_default) ?? def.sub_choices[0]
|
||||||
|
init[def.id] = subDef
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return init
|
||||||
|
})
|
||||||
|
|
||||||
|
// Shared-subset selections: { [setId]: subChoice | null }
|
||||||
|
const [selectedSharedSubs, setSelectedSharedSubs] = useState(() => {
|
||||||
|
const init = {}
|
||||||
|
preferenceSets.forEach(ps => {
|
||||||
|
if (ps.shared_subset?.choices?.length > 0) {
|
||||||
|
const selectedChoice = ps.default_choice_id != null
|
||||||
|
? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
|
||||||
|
: null
|
||||||
|
if (!selectedChoice || !selectedChoice.disables_subset) {
|
||||||
|
const subDef = ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0]
|
||||||
|
init[ps.id] = subDef
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return init
|
||||||
|
})
|
||||||
|
|
||||||
|
// Option sub-choices: { [optionId]: subChoice | null }
|
||||||
|
// Initialise with any option that has a default sub-choice pre-selected — but only
|
||||||
|
// if the option itself is checked by default (options are all unchecked initially).
|
||||||
|
const [selectedOptionSubs, setSelectedOptionSubs] = useState({})
|
||||||
|
|
||||||
function selectPreference(setId, choice) {
|
function selectPreference(setId, choice) {
|
||||||
setSelectedPreferences(prev => ({ ...prev, [setId]: choice }))
|
setSelectedPreferences(prev => ({ ...prev, [setId]: choice }))
|
||||||
|
if (choice && choice.sub_choices?.length > 0) {
|
||||||
|
const subDef = choice.sub_choices.find(s => s.is_default) ?? choice.sub_choices[0]
|
||||||
|
setSelectedSubChoices(prev => ({ ...prev, [choice.id]: subDef }))
|
||||||
|
}
|
||||||
|
const ps = preferenceSets.find(p => p.id === setId)
|
||||||
|
if (ps?.shared_subset?.choices?.length > 0 && !choice?.disables_subset) {
|
||||||
|
setSelectedSharedSubs(prev => {
|
||||||
|
if (prev[setId] != null) return prev
|
||||||
|
const subDef = ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0]
|
||||||
|
return { ...prev, [setId]: subDef }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSubChoice(parentChoiceId, sub) {
|
||||||
|
setSelectedSubChoices(prev => ({ ...prev, [parentChoiceId]: sub }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSharedSub(setId, sub) {
|
||||||
|
setSelectedSharedSubs(prev => ({ ...prev, [setId]: sub }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleOption(opt) {
|
function toggleOption(opt) {
|
||||||
setSelectedOptions(prev => {
|
setSelectedOptions(prev => {
|
||||||
const exists = prev.find(o => o.id === opt.id)
|
const exists = prev.find(o => o.id === opt.id)
|
||||||
if (exists) return prev.filter(o => o.id !== opt.id)
|
if (exists) {
|
||||||
|
// Deselecting: also clear its sub-choice selection
|
||||||
|
setSelectedOptionSubs(s => { const n = { ...s }; delete n[opt.id]; return n })
|
||||||
|
return prev.filter(o => o.id !== opt.id)
|
||||||
|
}
|
||||||
|
// Selecting: pre-select default sub-choice if any
|
||||||
|
if (opt.sub_choices?.length > 0) {
|
||||||
|
const subDef = opt.sub_choices.find(s => s.is_default) ?? opt.sub_choices[0]
|
||||||
|
setSelectedOptionSubs(s => ({ ...s, [opt.id]: subDef }))
|
||||||
|
}
|
||||||
return [...prev, { id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0 }]
|
return [...prev, { id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0 }]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectOptionSub(optId, sub) {
|
||||||
|
setSelectedOptionSubs(prev => ({ ...prev, [optId]: sub }))
|
||||||
|
}
|
||||||
|
|
||||||
function toggleIngredient(ing) {
|
function toggleIngredient(ing) {
|
||||||
setRemovedIngredients(prev =>
|
setRemovedIngredients(prev =>
|
||||||
prev.includes(ing.name) ? prev.filter(n => n !== ing.name) : [...prev, ing.name]
|
prev.includes(ing.name) ? prev.filter(n => n !== ing.name) : [...prev, ing.name]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefExtra = Object.values(selectedPreferences).reduce((s, ch) => s + (ch?.extra_cost ?? 0), 0)
|
// Check whether any checked option with sub_choices is missing its sub-choice selection
|
||||||
const extraPrice = selectedOptions.reduce((s, o) => s + (o.price_delta ?? o.extra_cost ?? 0), 0) + prefExtra
|
const optionSubsMissing = selectedOptions.some(o => {
|
||||||
const totalPrice = (product.base_price + extraPrice) * quantity
|
const full = options.find(opt => opt.id === o.id)
|
||||||
|
return full?.sub_choices?.length > 0 && selectedOptionSubs[o.id] == null
|
||||||
|
})
|
||||||
|
|
||||||
|
function isPrefSetComplete(ps) {
|
||||||
|
const choice = selectedPreferences[ps.id]
|
||||||
|
if (choice == null) return false
|
||||||
|
if (choice.sub_choices?.length > 0 && selectedSubChoices[choice.id] == null) return false
|
||||||
|
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset && selectedSharedSubs[ps.id] == null) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPrefsSelected = preferenceSets.every(isPrefSetComplete)
|
||||||
|
const unselectedPrefs = preferenceSets.filter(ps => !isPrefSetComplete(ps))
|
||||||
|
const canAdd = allPrefsSelected && !optionSubsMissing
|
||||||
|
|
||||||
|
const prefExtra = preferenceSets.reduce((s, ps) => {
|
||||||
|
const choice = selectedPreferences[ps.id]
|
||||||
|
if (!choice) return s
|
||||||
|
const inlineSub = choice.sub_choices?.length > 0 ? (selectedSubChoices[choice.id] ?? null) : null
|
||||||
|
const sharedSub = (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset)
|
||||||
|
? (selectedSharedSubs[ps.id] ?? null) : null
|
||||||
|
return s + (choice.extra_cost ?? 0) + (inlineSub?.extra_cost ?? 0) + (sharedSub?.extra_cost ?? 0)
|
||||||
|
}, 0)
|
||||||
|
const optionExtra = selectedOptions.reduce((s, o) => {
|
||||||
|
const subExtra = selectedOptionSubs[o.id]?.extra_cost ?? 0
|
||||||
|
return s + (o.price_delta ?? 0) + subExtra
|
||||||
|
}, 0)
|
||||||
|
const totalPrice = (product.base_price + optionExtra + prefExtra) * quantity
|
||||||
|
|
||||||
function handleAdd() {
|
function handleAdd() {
|
||||||
const prefChoices = Object.values(selectedPreferences)
|
if (!canAdd) return
|
||||||
.filter(Boolean)
|
const prefChoices = preferenceSets.flatMap(ps => {
|
||||||
.map(ch => ({ id: ch.id, name: ch.name, price_delta: ch.extra_cost ?? 0 }))
|
const choice = selectedPreferences[ps.id]
|
||||||
|
if (!choice) return []
|
||||||
|
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0 }]
|
||||||
|
const inlineSub = choice.sub_choices?.length > 0 ? (selectedSubChoices[choice.id] ?? null) : null
|
||||||
|
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0 })
|
||||||
|
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) {
|
||||||
|
const sharedSub = selectedSharedSubs[ps.id] ?? null
|
||||||
|
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0 })
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
})
|
||||||
|
|
||||||
|
const optionEntries = selectedOptions.flatMap(o => {
|
||||||
|
const entries = [{ id: o.id, name: o.name, price_delta: o.price_delta ?? 0 }]
|
||||||
|
const sub = selectedOptionSubs[o.id]
|
||||||
|
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0 })
|
||||||
|
return entries
|
||||||
|
})
|
||||||
|
|
||||||
onAdd({
|
onAdd({
|
||||||
product_id: product.id,
|
product_id: product.id,
|
||||||
quantity,
|
quantity,
|
||||||
selected_options: [...selectedOptions, ...prefChoices],
|
selected_options: [...optionEntries, ...prefChoices],
|
||||||
removed_ingredients: removedIngredients,
|
removed_ingredients: removedIngredients,
|
||||||
notes,
|
notes,
|
||||||
})
|
})
|
||||||
@@ -57,51 +181,138 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
|
|||||||
<h2 className="modal-title">{product.name}</h2>
|
<h2 className="modal-title">{product.name}</h2>
|
||||||
<p className="modal-price">{Number(totalPrice).toFixed(2)} €</p>
|
<p className="modal-price">{Number(totalPrice).toFixed(2)} €</p>
|
||||||
|
|
||||||
|
{/* ── Checkbox options with optional sub-choices ── */}
|
||||||
{options.length > 0 && (
|
{options.length > 0 && (
|
||||||
<section className="modal-section">
|
<section className="modal-section">
|
||||||
<h3>Επιλογές</h3>
|
<h3>Επιλογές</h3>
|
||||||
{options.map(opt => (
|
{options.map(opt => {
|
||||||
<label key={opt.id} className="modal-option">
|
const isChecked = !!selectedOptions.find(o => o.id === opt.id)
|
||||||
<input
|
const hasSubs = opt.sub_choices?.length > 0
|
||||||
type="checkbox"
|
const subMissing = isChecked && hasSubs && selectedOptionSubs[opt.id] == null
|
||||||
checked={!!selectedOptions.find(o => o.id === opt.id)}
|
return (
|
||||||
onChange={() => toggleOption(opt)}
|
<div key={opt.id}>
|
||||||
/>
|
<label className="modal-option">
|
||||||
|
<input type="checkbox" checked={isChecked} onChange={() => toggleOption(opt)} />
|
||||||
<span>{opt.name}</span>
|
<span>{opt.name}</span>
|
||||||
{(opt.extra_cost ?? 0) !== 0 && <span className="option-price">{(opt.extra_cost ?? 0) > 0 ? '+' : ''}{Number(opt.extra_cost).toFixed(2)} €</span>}
|
{(opt.extra_cost ?? 0) !== 0 && (
|
||||||
|
<span className="option-price">{opt.extra_cost > 0 ? '+' : ''}{Number(opt.extra_cost).toFixed(2)} €</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
{isChecked && hasSubs && (
|
||||||
|
<div style={{
|
||||||
|
marginLeft: 24, marginTop: 4, marginBottom: 6,
|
||||||
|
padding: '8px 12px', borderRadius: 8,
|
||||||
|
borderLeft: `3px solid ${subMissing ? '#ef4444' : '#6366f1'}`,
|
||||||
|
}}>
|
||||||
|
{subMissing && <p style={{ fontSize: 12, color: '#ef4444', margin: '0 0 4px' }}>— απαιτείται επιλογή</p>}
|
||||||
|
{opt.sub_choices.map((sub, si) => (
|
||||||
|
<label key={si} className="modal-option" style={{ fontSize: 14 }}>
|
||||||
|
<input type="radio" name={`optsub-${opt.id}`}
|
||||||
|
checked={selectedOptionSubs[opt.id]?.name === sub.name}
|
||||||
|
onChange={() => selectOptionSub(opt.id, sub)} />
|
||||||
|
<span>{sub.name}</span>
|
||||||
|
{(sub.extra_cost ?? 0) !== 0 && (
|
||||||
|
<span className="option-price">{sub.extra_cost > 0 ? '+' : ''}{Number(sub.extra_cost).toFixed(2)} €</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{preferenceSets.map(ps => (
|
{/* ── Preference sets ── */}
|
||||||
<section key={ps.id} className="modal-section">
|
{preferenceSets.map(ps => {
|
||||||
<h3>{ps.name}</h3>
|
const missing = !isPrefSetComplete(ps)
|
||||||
{ps.choices.map(ch => (
|
const selectedChoice = selectedPreferences[ps.id] ?? null
|
||||||
<label key={ch.id} className="modal-option">
|
const showSharedSubset = ps.shared_subset?.choices?.length > 0
|
||||||
<input
|
&& selectedChoice != null
|
||||||
type="radio"
|
&& !selectedChoice.disables_subset
|
||||||
name={`pref-${ps.id}`}
|
const sharedMissing = showSharedSubset && selectedSharedSubs[ps.id] == null
|
||||||
checked={selectedPreferences[ps.id]?.id === ch.id}
|
|
||||||
onChange={() => selectPreference(ps.id, ch)}
|
return (
|
||||||
/>
|
<section key={ps.id} className="modal-section"
|
||||||
|
style={missing ? { border: '1.5px solid #ef4444', borderRadius: 10, padding: '10px 12px' } : {}}>
|
||||||
|
<h3 style={{ color: missing ? '#ef4444' : undefined }}>
|
||||||
|
{ps.name}
|
||||||
|
{missing && <span style={{ fontSize: 12, marginLeft: 6, fontWeight: 400 }}>— απαιτείται επιλογή</span>}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{ps.choices.map(ch => {
|
||||||
|
const isSelected = selectedPreferences[ps.id]?.id === ch.id
|
||||||
|
const hasSubs = ch.sub_choices?.length > 0
|
||||||
|
const subMissing = isSelected && hasSubs && selectedSubChoices[ch.id] == null
|
||||||
|
return (
|
||||||
|
<div key={ch.id}>
|
||||||
|
<label className="modal-option">
|
||||||
|
<input type="radio" name={`pref-${ps.id}`} checked={isSelected}
|
||||||
|
onChange={() => selectPreference(ps.id, ch)} />
|
||||||
<span>{ch.name}</span>
|
<span>{ch.name}</span>
|
||||||
{(ch.extra_cost ?? 0) !== 0 && <span className="option-price">{(ch.extra_cost ?? 0) > 0 ? '+' : ''}{Number(ch.extra_cost).toFixed(2)} €</span>}
|
{(ch.extra_cost ?? 0) !== 0 && (
|
||||||
|
<span className="option-price">{ch.extra_cost > 0 ? '+' : ''}{Number(ch.extra_cost).toFixed(2)} €</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
{isSelected && hasSubs && (
|
||||||
|
<div style={{
|
||||||
|
marginLeft: 24, marginTop: 4, marginBottom: 6,
|
||||||
|
padding: '8px 12px', borderRadius: 8,
|
||||||
|
borderLeft: `3px solid ${subMissing ? '#ef4444' : '#6366f1'}`,
|
||||||
|
}}>
|
||||||
|
{subMissing && <p style={{ fontSize: 12, color: '#ef4444', margin: '0 0 4px' }}>— απαιτείται επιλογή</p>}
|
||||||
|
{ch.sub_choices.map((sub, si) => (
|
||||||
|
<label key={si} className="modal-option" style={{ fontSize: 14 }}>
|
||||||
|
<input type="radio" name={`sub-${ch.id}`}
|
||||||
|
checked={selectedSubChoices[ch.id]?.name === sub.name}
|
||||||
|
onChange={() => selectSubChoice(ch.id, sub)} />
|
||||||
|
<span>{sub.name}</span>
|
||||||
|
{(sub.extra_cost ?? 0) !== 0 && (
|
||||||
|
<span className="option-price">{sub.extra_cost > 0 ? '+' : ''}{Number(sub.extra_cost).toFixed(2)} €</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</section>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{showSharedSubset && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 8, marginLeft: 8, padding: '8px 12px', borderRadius: 8,
|
||||||
|
borderLeft: `3px solid ${sharedMissing ? '#ef4444' : '#6366f1'}`,
|
||||||
|
}}>
|
||||||
|
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, color: sharedMissing ? '#ef4444' : '#4338ca' }}>
|
||||||
|
{ps.shared_subset.name}
|
||||||
|
{sharedMissing && <span style={{ fontSize: 12, marginLeft: 6, fontWeight: 400 }}>— απαιτείται επιλογή</span>}
|
||||||
|
</p>
|
||||||
|
{ps.shared_subset.choices.map((sub, si) => (
|
||||||
|
<label key={si} className="modal-option" style={{ fontSize: 14 }}>
|
||||||
|
<input type="radio" name={`shared-${ps.id}`}
|
||||||
|
checked={selectedSharedSubs[ps.id]?.name === sub.name}
|
||||||
|
onChange={() => selectSharedSub(ps.id, sub)} />
|
||||||
|
<span>{sub.name}</span>
|
||||||
|
{(sub.extra_cost ?? 0) !== 0 && (
|
||||||
|
<span className="option-price">{sub.extra_cost > 0 ? '+' : ''}{Number(sub.extra_cost).toFixed(2)} €</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* ── Remove ingredients ── */}
|
||||||
{ingredients.length > 0 && (
|
{ingredients.length > 0 && (
|
||||||
<section className="modal-section">
|
<section className="modal-section">
|
||||||
<h3>Αφαίρεση υλικών</h3>
|
<h3>Αφαίρεση υλικών</h3>
|
||||||
{ingredients.map(ing => (
|
{ingredients.map(ing => (
|
||||||
<label key={ing.id} className="modal-option modal-option--remove">
|
<label key={ing.id} className="modal-option modal-option--remove">
|
||||||
<input
|
<input type="checkbox" checked={removedIngredients.includes(ing.name)}
|
||||||
type="checkbox"
|
onChange={() => toggleIngredient(ing)} />
|
||||||
checked={removedIngredients.includes(ing.name)}
|
|
||||||
onChange={() => toggleIngredient(ing)}
|
|
||||||
/>
|
|
||||||
<span>χωρίς {ing.name}</span>
|
<span>χωρίς {ing.name}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
@@ -110,13 +321,8 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
|
|||||||
|
|
||||||
<section className="modal-section">
|
<section className="modal-section">
|
||||||
<h3>Σημείωση</h3>
|
<h3>Σημείωση</h3>
|
||||||
<textarea
|
<textarea className="modal-notes" placeholder="π.χ. χωρίς αλάτι..."
|
||||||
className="modal-notes"
|
value={notes} onChange={e => setNotes(e.target.value)} rows={2} />
|
||||||
placeholder="π.χ. χωρίς αλάτι..."
|
|
||||||
value={notes}
|
|
||||||
onChange={e => setNotes(e.target.value)}
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="modal-qty">
|
<div className="modal-qty">
|
||||||
@@ -125,7 +331,19 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
|
|||||||
<button className="qty-btn" onClick={() => setQuantity(q => q + 1)}>+</button>
|
<button className="qty-btn" onClick={() => setQuantity(q => q + 1)}>+</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="btn btn--primary btn--lg" onClick={handleAdd} style={{ width: '100%', marginTop: 16 }}>
|
{!allPrefsSelected && (
|
||||||
|
<p style={{ color: '#ef4444', fontSize: 13, textAlign: 'center', marginTop: 8 }}>
|
||||||
|
Επιλέξτε: {unselectedPrefs.map(ps => ps.name).join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{optionSubsMissing && (
|
||||||
|
<p style={{ color: '#ef4444', fontSize: 13, textAlign: 'center', marginTop: 4 }}>
|
||||||
|
Επιλέξτε υπο-επιλογή για τις επιλεγμένες επιλογές
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="btn btn--primary btn--lg" onClick={handleAdd} disabled={!canAdd}
|
||||||
|
style={{ width: '100%', marginTop: 16, opacity: canAdd ? 1 : 0.45 }}>
|
||||||
Προσθήκη στην παραγγελία
|
Προσθήκη στην παραγγελία
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,11 +15,8 @@ export default function LoginPage() {
|
|||||||
setError('')
|
setError('')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ username, password: pin })
|
const { data } = await client.post('/api/auth/login', { username, pin })
|
||||||
const { data } = await client.post('/api/auth/login', params, {
|
login({ id: data.user.id, username: data.user.username, role: data.user.role }, data.access_token)
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
})
|
|
||||||
login({ id: data.user_id, username: data.username, role: data.role }, data.access_token)
|
|
||||||
navigate('/tables')
|
navigate('/tables')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.detail || 'Λανθασμένα στοιχεία')
|
setError(err.response?.data?.detail || 'Λανθασμένα στοιχεία')
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import TableCard from '../components/TableCard'
|
import TableCard from '../components/TableCard'
|
||||||
import ConnectionBanner from '../components/ConnectionBanner'
|
import ConnectionBanner from '../components/ConnectionBanner'
|
||||||
@@ -11,9 +11,13 @@ const FILTER_LABELS = { all: 'Όλα', mine: 'Δικά μου', free: 'Ελεύ
|
|||||||
export default function TableListPage() {
|
export default function TableListPage() {
|
||||||
const { user, logout } = useAuthStore()
|
const { user, logout } = useAuthStore()
|
||||||
const [tables, setTables] = useState([])
|
const [tables, setTables] = useState([])
|
||||||
|
const [groups, setGroups] = useState([])
|
||||||
const [orders, setOrders] = useState([])
|
const [orders, setOrders] = useState([])
|
||||||
const [filter, setFilter] = useState('all')
|
const [filter, setFilter] = useState('all')
|
||||||
const [offline, setOffline] = useState(false)
|
const [offline, setOffline] = useState(false)
|
||||||
|
const [zoneOpen, setZoneOpen] = useState(false)
|
||||||
|
const [selectedZones, setSelectedZones] = useState(new Set())
|
||||||
|
const zoneRef = useRef(null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -22,14 +26,25 @@ export default function TableListPage() {
|
|||||||
return () => window.removeEventListener('backend-offline', handler)
|
return () => window.removeEventListener('backend-offline', handler)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Close zone dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
function onClick(e) {
|
||||||
|
if (zoneRef.current && !zoneRef.current.contains(e.target)) setZoneOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onClick)
|
||||||
|
return () => document.removeEventListener('mousedown', onClick)
|
||||||
|
}, [])
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [tablesRes, ordersRes] = await Promise.all([
|
const [tablesRes, ordersRes, groupsRes] = await Promise.all([
|
||||||
client.get('/api/tables/'),
|
client.get('/api/tables/'),
|
||||||
client.get('/api/orders/my'),
|
client.get('/api/orders/my'),
|
||||||
|
client.get('/api/tables/groups'),
|
||||||
])
|
])
|
||||||
setTables(tablesRes.data)
|
setTables(tablesRes.data)
|
||||||
setOrders(ordersRes.data)
|
setOrders(ordersRes.data)
|
||||||
|
setGroups(groupsRes.data)
|
||||||
setOffline(false)
|
setOffline(false)
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -40,10 +55,19 @@ export default function TableListPage() {
|
|||||||
return orders.find(o => o.table_id === tableId && ['open', 'partially_paid'].includes(o.status))
|
return orders.find(o => o.table_id === tableId && ['open', 'partially_paid'].includes(o.status))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleZone(id) {
|
||||||
|
setSelectedZones(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const filtered = tables.filter(t => {
|
const filtered = tables.filter(t => {
|
||||||
const order = getOrder(t.id)
|
const order = getOrder(t.id)
|
||||||
if (filter === 'free') return !order
|
if (filter === 'free' && order) return false
|
||||||
if (filter === 'mine') return order && order.waiters?.some(w => w.waiter_id === user?.id)
|
if (filter === 'mine' && !(order && order.waiters?.some(w => w.waiter_id === user?.id))) return false
|
||||||
|
if (selectedZones.size > 0 && !selectedZones.has(t.group_id ?? 'none')) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -52,6 +76,8 @@ export default function TableListPage() {
|
|||||||
navigate('/login')
|
navigate('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const zoneActive = selectedZones.size > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<header className="top-bar">
|
<header className="top-bar">
|
||||||
@@ -68,6 +94,66 @@ export default function TableListPage() {
|
|||||||
{FILTER_LABELS[f]}
|
{FILTER_LABELS[f]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Zone filter */}
|
||||||
|
<div ref={zoneRef} style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
className={`filter-tab ${zoneActive ? 'filter-tab--active' : ''}`}
|
||||||
|
onClick={() => setZoneOpen(o => !o)}
|
||||||
|
>
|
||||||
|
Ζώνη{zoneActive ? ` (${selectedZones.size})` : ''}
|
||||||
|
</button>
|
||||||
|
{zoneOpen && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: '110%', right: 0, zIndex: 100,
|
||||||
|
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12,
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', minWidth: 180, padding: 8,
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedZones(new Set())}
|
||||||
|
style={{
|
||||||
|
display: 'block', width: '100%', textAlign: 'left',
|
||||||
|
padding: '8px 12px', borderRadius: 8, fontSize: 14,
|
||||||
|
color: selectedZones.size === 0 ? '#fff' : '#374151',
|
||||||
|
background: selectedZones.size === 0 ? '#4f46e5' : 'transparent',
|
||||||
|
border: 'none', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Όλες οι ζώνες
|
||||||
|
</button>
|
||||||
|
{groups.map(g => (
|
||||||
|
<button
|
||||||
|
key={g.id}
|
||||||
|
onClick={() => toggleZone(g.id)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||||
|
textAlign: 'left', padding: '8px 12px', borderRadius: 8, fontSize: 14,
|
||||||
|
color: selectedZones.has(g.id) ? '#fff' : '#374151',
|
||||||
|
background: selectedZones.has(g.id) ? '#4f46e5' : 'transparent',
|
||||||
|
border: 'none', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g.color && <span style={{ width: 10, height: 10, borderRadius: '50%', background: g.color, display: 'inline-block', flexShrink: 0 }} />}
|
||||||
|
{g.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{tables.some(t => !t.group_id) && (
|
||||||
|
<button
|
||||||
|
onClick={() => toggleZone('none')}
|
||||||
|
style={{
|
||||||
|
display: 'block', width: '100%', textAlign: 'left',
|
||||||
|
padding: '8px 12px', borderRadius: 8, fontSize: 14,
|
||||||
|
color: selectedZones.has('none') ? '#fff' : '#374151',
|
||||||
|
background: selectedZones.has('none') ? '#4f46e5' : 'transparent',
|
||||||
|
border: 'none', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Χωρίς ζώνη
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="table-grid">
|
<div className="table-grid">
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export default defineConfig({
|
|||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||||
runtimeCaching: [],
|
runtimeCaching: [],
|
||||||
},
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user