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:
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom'
|
||||
import useAuthStore from './store/authStore'
|
||||
import client from './api/client'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import TableListPage from './pages/TableListPage'
|
||||
import TableDetailPage from './pages/TableDetailPage'
|
||||
@@ -13,6 +14,19 @@ function ProtectedRoute({ children }) {
|
||||
return children
|
||||
}
|
||||
|
||||
// Rehydrates user object from token on every app load
|
||||
function AuthRehydrator() {
|
||||
const { token, user, login, logout } = useAuthStore()
|
||||
useEffect(() => {
|
||||
if (token && !user) {
|
||||
client.get('/api/auth/me')
|
||||
.then(r => login(r.data, token))
|
||||
.catch(() => logout())
|
||||
}
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
|
||||
function OfflineListener() {
|
||||
const navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
@@ -26,6 +40,7 @@ function OfflineListener() {
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthRehydrator />
|
||||
<OfflineListener />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
@@ -15,6 +15,11 @@ client.interceptors.response.use(
|
||||
err => {
|
||||
if (!err.response) {
|
||||
window.dispatchEvent(new Event('backend-offline'))
|
||||
} else if (err.response.status === 401) {
|
||||
// Token expired or user blocked — force logout
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('savedUsername')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user