Fix order saving, isMyOrder, blocked waiters, options pricing; add preferences, table groups, product images

Backend:
- OrderItemInput accepts option objects {id,name,price_delta} instead of int IDs
- extra_cost from selected options added to unit_price snapshot
- GET /api/products/?all=true for manager (includes unavailable)
- PUT /api/products/{id} now replaces options, ingredients, preference_sets
- POST /api/products/{id}/image — persistent image upload to /app/data/product_images
- New models: ProductPreferenceSet, ProductPreferenceChoice, TableGroup
- tables: group_id FK, hard delete (?hard=true), batch create POST /api/tables/batch
- GET /api/tables/groups + POST/PUT/DELETE groups endpoints
- POST /api/auth/me endpoint for token rehydration
- Auto-migration on startup for new columns

PWA:
- AuthRehydrator: fetches /auth/me on load so isMyOrder works after page reload
- 401 response force-logs out (covers blocked waiters)
- ItemOptionsModal: uses extra_cost correctly, shows preferences as radio buttons

Manager:
- ProductsPage: shows unavailable products greyed out, category color picker + reorder,
  full option/ingredient/preference editing, image upload
- TablesPage: table groups, auto-increment, deactivate vs hard delete, batch add
This commit is contained in:
2026-04-20 18:39:51 +03:00
parent 8f52156f5b
commit 24a029a8cc
16 changed files with 826 additions and 172 deletions

View File

@@ -8,12 +8,21 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
const options = product.options || []
const ingredients = product.ingredients || []
const preferenceSets = product.preference_sets || []
const [selectedPreferences, setSelectedPreferences] = useState(
Object.fromEntries(preferenceSets.map(ps => [ps.id, null]))
)
function selectPreference(setId, choice) {
setSelectedPreferences(prev => ({ ...prev, [setId]: choice }))
}
function toggleOption(opt) {
setSelectedOptions(prev => {
const exists = prev.find(o => o.id === opt.id)
if (exists) return prev.filter(o => o.id !== opt.id)
return [...prev, { id: opt.id, name: opt.name, price_delta: opt.price_delta }]
return [...prev, { id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0 }]
})
}
@@ -23,11 +32,21 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
)
}
const extraPrice = selectedOptions.reduce((s, o) => s + (o.price_delta || 0), 0)
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
function handleAdd() {
onAdd({ product_id: product.id, quantity, selected_options: selectedOptions, removed_ingredients: removedIngredients, notes })
const prefChoices = Object.values(selectedPreferences)
.filter(Boolean)
.map(ch => ({ id: ch.id, name: ch.name, price_delta: ch.extra_cost ?? 0 }))
onAdd({
product_id: product.id,
quantity,
selected_options: [...selectedOptions, ...prefChoices],
removed_ingredients: removedIngredients,
notes,
})
onClose()
}
@@ -49,12 +68,30 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
onChange={() => toggleOption(opt)}
/>
<span>{opt.name}</span>
{opt.price_delta > 0 && <span className="option-price">+{Number(opt.price_delta).toFixed(2)} </span>}
{(opt.extra_cost ?? 0) !== 0 && <span className="option-price">{(opt.extra_cost ?? 0) > 0 ? '+' : ''}{Number(opt.extra_cost).toFixed(2)} </span>}
</label>
))}
</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>
))}
{ingredients.length > 0 && (
<section className="modal-section">
<h3>Αφαίρεση υλικών</h3>