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:
2026-04-24 09:29:09 +03:00
parent d07c7634e6
commit 5acd880e92
4 changed files with 364 additions and 60 deletions

View File

@@ -10,40 +10,164 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
const ingredients = product.ingredients || []
const preferenceSets = product.preference_sets || []
const [selectedPreferences, setSelectedPreferences] = useState(
Object.fromEntries(preferenceSets.map(ps => [ps.id, null]))
// selectedPreferences: { [setId]: choice | 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) {
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) {
setSelectedOptions(prev => {
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 }]
})
}
function selectOptionSub(optId, sub) {
setSelectedOptionSubs(prev => ({ ...prev, [optId]: sub }))
}
function toggleIngredient(ing) {
setRemovedIngredients(prev =>
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)
const extraPrice = selectedOptions.reduce((s, o) => s + (o.price_delta ?? o.extra_cost ?? 0), 0) + prefExtra
const totalPrice = (product.base_price + extraPrice) * quantity
// Check whether any checked option with sub_choices is missing its sub-choice selection
const optionSubsMissing = selectedOptions.some(o => {
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() {
const prefChoices = Object.values(selectedPreferences)
.filter(Boolean)
.map(ch => ({ id: ch.id, name: ch.name, price_delta: ch.extra_cost ?? 0 }))
if (!canAdd) return
const prefChoices = preferenceSets.flatMap(ps => {
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({
product_id: product.id,
quantity,
selected_options: [...selectedOptions, ...prefChoices],
selected_options: [...optionEntries, ...prefChoices],
removed_ingredients: removedIngredients,
notes,
})
@@ -57,51 +181,138 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
<h2 className="modal-title">{product.name}</h2>
<p className="modal-price">{Number(totalPrice).toFixed(2)} </p>
{/* ── Checkbox options with optional sub-choices ── */}
{options.length > 0 && (
<section className="modal-section">
<h3>Επιλογές</h3>
{options.map(opt => (
<label key={opt.id} className="modal-option">
<input
type="checkbox"
checked={!!selectedOptions.find(o => o.id === opt.id)}
onChange={() => toggleOption(opt)}
/>
<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>}
</label>
))}
{options.map(opt => {
const isChecked = !!selectedOptions.find(o => o.id === opt.id)
const hasSubs = opt.sub_choices?.length > 0
const subMissing = isChecked && hasSubs && selectedOptionSubs[opt.id] == null
return (
<div key={opt.id}>
<label className="modal-option">
<input type="checkbox" checked={isChecked} onChange={() => toggleOption(opt)} />
<span>{opt.name}</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>
))}
</div>
)}
</div>
)
})}
</section>
)}
{preferenceSets.map(ps => (
<section key={ps.id} className="modal-section">
<h3>{ps.name}</h3>
{ps.choices.map(ch => (
<label key={ch.id} className="modal-option">
<input
type="radio"
name={`pref-${ps.id}`}
checked={selectedPreferences[ps.id]?.id === ch.id}
onChange={() => selectPreference(ps.id, ch)}
/>
<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>}
</label>
))}
</section>
))}
{/* ── Preference sets ── */}
{preferenceSets.map(ps => {
const missing = !isPrefSetComplete(ps)
const selectedChoice = selectedPreferences[ps.id] ?? null
const showSharedSubset = ps.shared_subset?.choices?.length > 0
&& selectedChoice != null
&& !selectedChoice.disables_subset
const sharedMissing = showSharedSubset && selectedSharedSubs[ps.id] == null
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>
{(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>
))}
</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 && (
<section className="modal-section">
<h3>Αφαίρεση υλικών</h3>
{ingredients.map(ing => (
<label key={ing.id} className="modal-option modal-option--remove">
<input
type="checkbox"
checked={removedIngredients.includes(ing.name)}
onChange={() => toggleIngredient(ing)}
/>
<input type="checkbox" checked={removedIngredients.includes(ing.name)}
onChange={() => toggleIngredient(ing)} />
<span>χωρίς {ing.name}</span>
</label>
))}
@@ -110,13 +321,8 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
<section className="modal-section">
<h3>Σημείωση</h3>
<textarea
className="modal-notes"
placeholder="π.χ. χωρίς αλάτι..."
value={notes}
onChange={e => setNotes(e.target.value)}
rows={2}
/>
<textarea className="modal-notes" placeholder="π.χ. χωρίς αλάτι..."
value={notes} onChange={e => setNotes(e.target.value)} rows={2} />
</section>
<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>
</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>
</div>