Dish suggestions are the heart of MiseOS. Line cooks propose dishes, head chefs review them, and approved suggestions automatically become dishes in the dish bank. Simple lifecycle — but building it meant touching almost every part of the frontend architecture.
The reason it was more complex than expected: the same data needs to serve two completely different purposes.
Two roles, two experiences#
Cook creates suggestion (PENDING)
→ Head chef reviews
→ APPROVED: becomes a dish in the dish bank
→ REJECTED: feedback is sent to the cookFrom the kitchen side, a cook needs to submit ideas, track their status, and see feedback. From the admin side, a head chef needs to review all suggestions across every station, filter by status and week, and approve or reject with one click.
Same domain model. Completely different UI.
This broke into four pages across two layouts:
/kitchen/suggestions → KitchenSuggestionsPage
/kitchen/suggestions/:id → KitchenSuggestionDetailPage
/admin/dish-suggestions → AdminSuggestionPage
/admin/dish-suggestions/:id → AdminSuggestionDetailPageThe shared layout decision#
Both detail pages display the same information — name, description, allergens, metadata, feedback. But the actions are completely different. Kitchen cooks see edit and delete. Admins see approve, edit, and reject with a feedback form.
Copying the display structure between both components would have worked, but it would mean changing a field or formatting a date in two places forever.
The solution was a SuggestionDetailLayout — a shared component that renders all the display and accepts children for the actions:
// Kitchen
<SuggestionDetailLayout suggestion={suggestion}>
<Button name="Slet" variant="danger" onClick={...} />
<Button name="Rediger" variant="primary" onClick={...} />
</SuggestionDetailLayout>
// Admin
<SuggestionDetailLayout suggestion={suggestion}>
<Button name="Afvis" variant="danger" onClick={...} />
<Button name="Rediger" variant="secondary" onClick={...} />
<Button name="Godkend" variant="primary" onClick={...} />
</SuggestionDetailLayout>Any change to how a suggestion is displayed happens once and appears in both contexts.
Error responsibility — pages vs forms#
SuggestionForm handles three types of feedback:
- Client-side validation — inline field errors before the API is called
- Backend 409 (deadline passed) — shown under the week selector, because that’s the field the user needs to change
- Other backend errors — toast notification via
NotificationContext
The key was understanding where errors belong. A deadline error is a field-level error even though it comes from the backend. A generic server error isn’t field-specific and belongs in a toast.
The responsibility pattern that held throughout: pages orchestrate, forms respond. KitchenSuggestionsPage calls the service and updates the list. If the API throws, it propagates up to SuggestionForm’s catch block — the page never catches it, because the form is the right place to respond to it.
Bugs along the way#
Most of the bugs this week had the same root cause: everything compiled, but the problems only appeared once the UI was exercised with real navigation and real API responses.
Event handler pitfalls. The suggestion list immediately redirected on every render — the cause was a missing arrow function: onClick={handleNavigate(suggestion.id)} calls the function during render instead of on click. The fix is onClick={() => handleNavigate(suggestion.id)}. A similar mistake placed fetchSuggestion() inside its own async definition instead of after it, so useEffect defined the function but never called it. Both compiled fine.
Silent runtime failures. After deleting a suggestion the success message passed through router state never appeared — location was the browser global window.location, which has no .state. The React Router import was present but unused, so the global silently took over. Fixed with const location = useLocation(). A separate crash — Objects are not valid as a React child — came from storing an entire error object in state instead of error.message.
The approve and reject UX#
Approve and reject are the most consequential actions in the admin view — getting the UX right mattered.
Approve uses a confirmation dialog. Approval automatically creates a dish in the dish bank — it’s irreversible. A single accidental click shouldn’t create an unintended dish. The dialog states the consequence explicitly: “Godkend X? Retten tilføjes automatisk til retternes bank.”
Reject uses an inline form, not a dialog. The admin needs to write feedback for the cook. A modal feels cramped for text input. Expanding the reject form inline beneath the action bar gives more space and is less disruptive.
The edit state uses an early return to swap out the entire component:
if (isEditing) {
return <SuggestionEditForm suggestion={suggestion} onSubmit={handleUpdate} ... />;
}This is simpler than conditionally hiding and showing parts of the detail view. When editing, nothing else is relevant — so nothing else renders.
The reject form renders outside SuggestionDetailLayout, below it in the tree. This was a deliberate structural choice: the layout owns the display, but the reject form is a separate action that expands below rather than replacing the detail. Toggling it also resets feedback and feedbackError state so the form is always clean when reopened.
The admin table#
The admin view needed a filterable table across all stations. Filtering is entirely client-side — the API supports server-side filtering via query parameters, but since all suggestions are fetched once on mount, filtering in the browser is faster and avoids extra requests. The filter chain is sequential and each step is independently readable:
const filteredSuggestions = suggestions
.filter(s => filter.status === 'ALL' || s.dishStatus === filter.status)
.filter(s => filter.station === 'ALL' || s.station.name.toUpperCase().includes(filter.station))
.filter(s => {
if (!searchTerm) return true;
return (
s.nameDA.toLowerCase().includes(searchTerm) ||
s.createdBy.firstName.toLowerCase().includes(searchTerm) ||
s.createdBy.lastName.toLowerCase().includes(searchTerm) ||
String(s.targetWeek).includes(searchTerm)
);
})
.filter(s => filter.week === 'ALL' || s.targetWeek === Number(filter.week));Reflections#
The shared layout component was the most satisfying decision of the week. It could easily have been skipped in favour of just copying the display code. The extraction took an hour. Changes to the display happen once and appear in both contexts — that’s already paid off several times.
The useEffect bug and the window.location bug have something in common: both compiled without error and failed silently at runtime. They are the kind of bugs that only show up when the feature is actually used. Keeping the browser console open and testing every interaction immediately after building it is the only reliable way to catch them early.
Button order on the admin detail matters more than expected: destructive action left, primary positive action right. The visual weight of the approve button on the right draws the eye to the intended default action. Small detail, real difference in how the UI feels.
Next step#
With the suggestion workflow complete, the system was still missing several core administrative features. Allergens, stations, users, and dishes all needed management interfaces before the application could support real menu planning. I will try to build as many as possible in week five, starting with allergens and stations since they are referenced by suggestions and menus. The goal is to have a fully functional admin section for all core resources by the end of the week.
This is part 14 of the MiseOS development log. Follow along as I build a management tool for professional kitchens, one commit at a time.
