diff --git a/docs/architecture/frontend-boundary.md b/docs/architecture/frontend-boundary.md
index 2a93fef..f4d64e7 100644
--- a/docs/architecture/frontend-boundary.md
+++ b/docs/architecture/frontend-boundary.md
@@ -5,6 +5,7 @@
- The UI does not access DAL or internal services directly.
- Route shell uses Ant Design layout/menu and keeps business views behind session checks.
- Unauthenticated users are redirected to the central auth host OIDC start endpoint.
+- Session-expired responses are treated as an auth boundary concern and trigger revalidation before the UI prompts for login again.
## Runtime Base URLs
@@ -16,10 +17,16 @@
## Protected Workflow Endpoints
- `GET /api/kitchen/ops/board?contextId=...`
+- `POST /api/kitchen/ops/work-items/claim`
+- `POST /api/kitchen/ops/work-items/release`
+- `POST /api/kitchen/ops/work-items/transition`
- `POST /api/kitchen/ops/board/priority`
## UI Workflow Coverage
-- Kitchen board lookup
-- Kitchen priority updates
-- Protected route shell for board, priority update, and session inspection
+- Kitchen board lanes with work-item detail and station coverage
+- Board event feed derived from the loaded lane state
+- Claim, release, transition, and priority operator actions
+- Latest operator result and recent action history
+- Session-expired handling with reauthentication guidance
+- Protected route shell for board, operator actions, and session inspection
diff --git a/docs/runbooks/local-development.md b/docs/runbooks/local-development.md
index 6f2dcb6..14838af 100644
--- a/docs/runbooks/local-development.md
+++ b/docs/runbooks/local-development.md
@@ -21,6 +21,13 @@ npm run dev
- Login is executed via central Thalos OIDC start endpoint.
- Business calls are gated behind session checks.
- Session cookies are sent with `credentials: include`.
+- Workflow calls that return `401` trigger session revalidation and then guide the user back to central login.
+
+## Available Screens
+
+- `/board`: lane-based kitchen board and board event feed
+- `/actions`: claim, release, transition, and priority operator controls
+- `/session`: current Thalos session profile payload
## Build
diff --git a/docs/runbooks/testing.md b/docs/runbooks/testing.md
index 5552af5..5f4fe75 100644
--- a/docs/runbooks/testing.md
+++ b/docs/runbooks/testing.md
@@ -15,10 +15,11 @@ npm run test:ci
## Coverage Scope
- `src/api/client.test.ts`: runtime-config precedence and fallback behavior.
-- `src/api/dashboardApi.test.ts`: endpoint path/query composition and payload mapping.
+- `src/api/dashboardApi.test.ts`: endpoint path/query composition for board and operator action flows.
- `src/auth/oidcLogin.test.ts`: OIDC start-url generation and safe return-url fallback.
-- `src/App.test.tsx`: central login screen, protected board flow, and priority update workflow.
+- `src/App.test.tsx`: central login screen, board lane loading, operator actions, and session-expired reauthentication guidance.
## Notes
- Use containerized Node execution when host `npm` is unavailable.
+- Prefer container-first validation before opening or updating runtime stack images.
diff --git a/src/App.test.tsx b/src/App.test.tsx
index d10f0a8..1e13ced 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -1,5 +1,6 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { ApiError } from './api/client';
vi.mock('./api/sessionApi', () => ({
getSessionMe: vi.fn(),
@@ -9,18 +10,30 @@ vi.mock('./api/sessionApi', () => ({
}));
vi.mock('./api/dashboardApi', () => ({
+ claimKitchenWorkItem: vi.fn(),
loadDashboard: vi.fn(),
- setKitchenOrderPriority: vi.fn()
+ releaseKitchenWorkItem: vi.fn(),
+ setKitchenOrderPriority: vi.fn(),
+ transitionKitchenWorkItem: vi.fn()
}));
-import { loadDashboard, setKitchenOrderPriority } from './api/dashboardApi';
+import {
+ claimKitchenWorkItem,
+ loadDashboard,
+ releaseKitchenWorkItem,
+ setKitchenOrderPriority,
+ transitionKitchenWorkItem
+} from './api/dashboardApi';
import { getSessionMe } from './api/sessionApi';
import App from './App';
describe('Kitchen Ops App', () => {
beforeEach(() => {
+ vi.mocked(claimKitchenWorkItem).mockReset();
vi.mocked(loadDashboard).mockReset();
+ vi.mocked(releaseKitchenWorkItem).mockReset();
vi.mocked(setKitchenOrderPriority).mockReset();
+ vi.mocked(transitionKitchenWorkItem).mockReset();
vi.mocked(getSessionMe).mockReset();
window.__APP_CONFIG__ = {
API_BASE_URL: 'http://localhost:8080',
@@ -42,14 +55,36 @@ describe('Kitchen Ops App', () => {
expect(link.href).toContain('tenantId=demo-tenant');
});
- it('loads kitchen board for authenticated users', async () => {
+ it('loads kitchen board with lanes and board events for authenticated users', async () => {
vi.mocked(getSessionMe).mockResolvedValue({
isAuthenticated: true,
subjectId: 'demo-user',
tenantId: 'demo-tenant',
- provider: 0
+ provider: 2
+ });
+ vi.mocked(loadDashboard).mockResolvedValue({
+ contextId: 'demo-context',
+ summary: '2 active kitchen lanes',
+ lanes: [
+ {
+ lane: 'Cooking',
+ items: [
+ {
+ workItemId: 'WORK-1',
+ orderId: 'ORD-1',
+ ticketId: 'TICK-1',
+ tableId: 'T-01',
+ station: 'Grill',
+ state: 'Cooking',
+ priority: 2,
+ claimedBy: 'chef-a',
+ etaMinutes: 12
+ }
+ ]
+ }
+ ],
+ availableStations: ['Grill', 'Expo']
});
- vi.mocked(loadDashboard).mockResolvedValue({ contextId: 'demo-context', summary: 'ready' });
render();
@@ -57,29 +92,72 @@ describe('Kitchen Ops App', () => {
fireEvent.click(screen.getByRole('button', { name: 'Load Kitchen Board' }));
await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context'));
+ expect(await screen.findByText('Lane: Cooking')).toBeInTheDocument();
+ expect(await screen.findByText('Cooking: WORK-1 at Grill is Cooking')).toBeInTheDocument();
});
- it('updates kitchen priority from action route', async () => {
+ it('runs operator actions from the actions route', async () => {
vi.mocked(getSessionMe).mockResolvedValue({
isAuthenticated: true,
subjectId: 'demo-user',
tenantId: 'demo-tenant',
- provider: 0
+ provider: 2
});
- vi.mocked(setKitchenOrderPriority).mockResolvedValue({
+ vi.mocked(claimKitchenWorkItem).mockResolvedValue({
contextId: 'demo-context',
- orderId: 'ORD-2200',
- updated: true,
- summary: 'updated'
+ workItemId: 'WORK-1',
+ claimed: true,
+ claimedBy: 'chef-a',
+ message: 'claimed'
+ });
+ vi.mocked(transitionKitchenWorkItem).mockResolvedValue({
+ orderId: 'ORD-1',
+ ticketId: 'TICK-1',
+ previousState: 'Cooking',
+ currentState: 'Ready',
+ transitioned: true,
+ error: null
});
render();
- await waitFor(() => expect(screen.getByText('Set Priority')).toBeInTheDocument());
- fireEvent.click(screen.getByText('Set Priority'));
- fireEvent.change(screen.getByPlaceholderText('Order Id'), { target: { value: 'ORD-2200' } });
- fireEvent.click(screen.getByRole('button', { name: 'Set Kitchen Priority' }));
+ await waitFor(() => expect(screen.getByText('Operator Actions')).toBeInTheDocument());
+ fireEvent.click(screen.getByText('Operator Actions'));
+ fireEvent.click(screen.getByRole('button', { name: 'Claim Work Item' }));
- await waitFor(() => expect(setKitchenOrderPriority).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(claimKitchenWorkItem).toHaveBeenCalledTimes(1));
+ expect(await screen.findAllByText('WORK-1')).toHaveLength(2);
+ expect(screen.getAllByText('chef-a').length).toBeGreaterThan(0);
+
+ fireEvent.change(screen.getByPlaceholderText('Order Id'), { target: { value: 'ORD-1' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Transition Work Item' }));
+
+ await waitFor(() => expect(transitionKitchenWorkItem).toHaveBeenCalledTimes(1));
+ expect(await screen.findByText('Cooking -> Ready')).toBeInTheDocument();
+ });
+
+ it('shows reauthentication guidance when board loading returns session expired', async () => {
+ vi.mocked(getSessionMe)
+ .mockResolvedValueOnce({
+ isAuthenticated: true,
+ subjectId: 'demo-user',
+ tenantId: 'demo-tenant',
+ provider: 2
+ })
+ .mockResolvedValueOnce({
+ isAuthenticated: false,
+ subjectId: '',
+ tenantId: '',
+ provider: 2
+ });
+ vi.mocked(loadDashboard).mockRejectedValue(new ApiError(401, 'No active session.', 'session_missing', 'corr-1'));
+
+ render();
+
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Load Kitchen Board' })).toBeInTheDocument());
+ fireEvent.click(screen.getByRole('button', { name: 'Load Kitchen Board' }));
+
+ await waitFor(() => expect(screen.getByText('Authentication Required')).toBeInTheDocument());
+ expect(screen.getByText('Session expired')).toBeInTheDocument();
});
});
diff --git a/src/App.tsx b/src/App.tsx
index eaf4a7b..8b7643f 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,15 +1,25 @@
-import { DeploymentUnitOutlined, OrderedListOutlined, ReloadOutlined, ToolOutlined } from '@ant-design/icons';
+import {
+ ClockCircleOutlined,
+ DeploymentUnitOutlined,
+ OrderedListOutlined,
+ ReloadOutlined,
+ SyncOutlined,
+ ToolOutlined
+} from '@ant-design/icons';
import {
Alert,
Button,
Card,
Descriptions,
+ Empty,
Form,
Input,
InputNumber,
Layout,
+ List,
Menu,
Result,
+ Select,
Space,
Spin,
Table,
@@ -18,25 +28,76 @@ import {
} from 'antd';
import { type ReactNode, useMemo, useState } from 'react';
import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
+import { ApiError } from './api/client';
import {
+ claimKitchenWorkItem,
loadDashboard,
+ releaseKitchenWorkItem,
setKitchenOrderPriority,
+ transitionKitchenWorkItem,
+ type ClaimKitchenWorkItemRequest,
+ type ClaimKitchenWorkItemResponse,
+ type KitchenBoardItem,
type KitchenOpsBoardResponse,
+ type ReleaseKitchenWorkItemRequest,
+ type ReleaseKitchenWorkItemResponse,
type SetKitchenOrderPriorityRequest,
- type SetKitchenOrderPriorityResponse
+ type SetKitchenOrderPriorityResponse,
+ type TransitionKitchenWorkItemRequest,
+ type TransitionKitchenWorkItemResponse
} from './api/dashboardApi';
import type { IdentityProvider } from './api/sessionApi';
import { buildGoogleOidcStartUrl } from './auth/oidcLogin';
import { SessionProvider, useSessionContext } from './auth/sessionContext';
-type AppRoute = '/board' | '/priority' | '/session';
+type AppRoute = '/board' | '/actions' | '/session';
+
+type WorkflowState = {
+ error: string | null;
+ sessionExpired: boolean;
+};
+
+type ActionEvent =
+ | { kind: 'claim'; response: ClaimKitchenWorkItemResponse }
+ | { kind: 'release'; response: ReleaseKitchenWorkItemResponse }
+ | { kind: 'transition'; response: TransitionKitchenWorkItemResponse }
+ | { kind: 'priority'; response: SetKitchenOrderPriorityResponse };
const routeItems: Array<{ key: AppRoute; label: string; icon: ReactNode }> = [
{ key: '/board', label: 'Kitchen Board', icon: },
- { key: '/priority', label: 'Set Priority', icon: },
+ { key: '/actions', label: 'Operator Actions', icon: },
{ key: '/session', label: 'Session', icon: }
];
+const boardColumns = [
+ { title: 'Work Item', dataIndex: 'workItemId' },
+ { title: 'Order Id', dataIndex: 'orderId' },
+ { title: 'Ticket Id', dataIndex: 'ticketId' },
+ { title: 'Table', dataIndex: 'tableId' },
+ { title: 'Station', dataIndex: 'station' },
+ {
+ title: 'State',
+ dataIndex: 'state',
+ render: (value: string) => {value}
+ },
+ { title: 'Priority', dataIndex: 'priority' },
+ { title: 'Claimed By', render: (_: unknown, record: KitchenBoardItem) => record.claimedBy ?? 'Unclaimed' },
+ { title: 'ETA (min)', dataIndex: 'etaMinutes' }
+];
+
+function getActionEventKey(event: ActionEvent) {
+ switch (event.kind) {
+ case 'claim':
+ return `claim:${event.response.contextId}:${event.response.workItemId}:${event.response.claimedBy}`;
+ case 'release':
+ return `release:${event.response.contextId}:${event.response.workItemId}:${event.response.releasedBy}`;
+ case 'transition':
+ return `transition:${event.response.orderId}:${event.response.ticketId}:${event.response.currentState}`;
+ case 'priority':
+ return `priority:${event.response.contextId}:${event.response.workItemId}:${event.response.priority}`;
+ }
+}
+
function App() {
return (
@@ -54,11 +115,14 @@ function KitchenOpsShell() {
const [contextId, setContextId] = useState('demo-context');
const [boardPayload, setBoardPayload] = useState(null);
- const [priorityResponse, setPriorityResponse] = useState(null);
- const [priorityHistory, setPriorityHistory] = useState([]);
- const [globalError, setGlobalError] = useState(null);
+ const [lastAction, setLastAction] = useState(null);
+ const [actionHistory, setActionHistory] = useState([]);
+ const [workflowState, setWorkflowState] = useState({ error: null, sessionExpired: false });
const [loadingBoard, setLoadingBoard] = useState(false);
- const [submittingPriority, setSubmittingPriority] = useState(false);
+ const [claimingItem, setClaimingItem] = useState(false);
+ const [releasingItem, setReleasingItem] = useState(false);
+ const [transitioningItem, setTransitioningItem] = useState(false);
+ const [updatingPriority, setUpdatingPriority] = useState(false);
const loginUrl = useMemo(() => buildGoogleOidcStartUrl(window.location.href), []);
const selectedKey = useMemo(() => {
@@ -66,32 +130,88 @@ function KitchenOpsShell() {
return candidate?.key ?? '/board';
}, [location.pathname]);
+ const clearWorkflowError = () => setWorkflowState({ error: null, sessionExpired: false });
+
+ const handleWorkflowFailure = async (err: unknown, fallbackMessage: string) => {
+ if (err instanceof ApiError && err.status === 401) {
+ setWorkflowState({ error: 'Your session expired. Sign in again to continue kitchen operations.', sessionExpired: true });
+ await session.revalidate();
+ return;
+ }
+
+ setWorkflowState({
+ error: err instanceof Error ? err.message : fallbackMessage,
+ sessionExpired: false
+ });
+ };
+
+ const pushActionHistory = (event: ActionEvent) => {
+ setLastAction(event);
+ setActionHistory((previous) => [event, ...previous].slice(0, 8));
+ };
+
const loadBoard = async () => {
setLoadingBoard(true);
- setGlobalError(null);
+ clearWorkflowError();
try {
const payload = await loadDashboard(contextId);
setBoardPayload(payload);
} catch (err) {
- setGlobalError(err instanceof Error ? err.message : 'Failed to load kitchen board.');
+ await handleWorkflowFailure(err, 'Failed to load kitchen board.');
} finally {
setLoadingBoard(false);
}
};
- const updatePriority = async (request: SetKitchenOrderPriorityRequest) => {
- setSubmittingPriority(true);
- setGlobalError(null);
+ const executeClaim = async (request: ClaimKitchenWorkItemRequest) => {
+ setClaimingItem(true);
+ clearWorkflowError();
+ try {
+ const payload = await claimKitchenWorkItem(request);
+ pushActionHistory({ kind: 'claim', response: payload });
+ } catch (err) {
+ await handleWorkflowFailure(err, 'Failed to claim kitchen work item.');
+ } finally {
+ setClaimingItem(false);
+ }
+ };
+ const executeRelease = async (request: ReleaseKitchenWorkItemRequest) => {
+ setReleasingItem(true);
+ clearWorkflowError();
+ try {
+ const payload = await releaseKitchenWorkItem(request);
+ pushActionHistory({ kind: 'release', response: payload });
+ } catch (err) {
+ await handleWorkflowFailure(err, 'Failed to release kitchen work item.');
+ } finally {
+ setReleasingItem(false);
+ }
+ };
+
+ const executeTransition = async (request: TransitionKitchenWorkItemRequest) => {
+ setTransitioningItem(true);
+ clearWorkflowError();
+ try {
+ const payload = await transitionKitchenWorkItem(request);
+ pushActionHistory({ kind: 'transition', response: payload });
+ } catch (err) {
+ await handleWorkflowFailure(err, 'Failed to transition kitchen work item.');
+ } finally {
+ setTransitioningItem(false);
+ }
+ };
+
+ const executePriorityUpdate = async (request: SetKitchenOrderPriorityRequest) => {
+ setUpdatingPriority(true);
+ clearWorkflowError();
try {
const payload = await setKitchenOrderPriority(request);
- setPriorityResponse(payload);
- // Keep recent responses bounded so the session view stays readable over long demos.
- setPriorityHistory((previous) => [payload, ...previous].slice(0, 8));
+ pushActionHistory({ kind: 'priority', response: payload });
} catch (err) {
- setGlobalError(err instanceof Error ? err.message : 'Failed to update kitchen order priority.');
+ await handleWorkflowFailure(err, 'Failed to update kitchen order priority.');
} finally {
- setSubmittingPriority(false);
+ setUpdatingPriority(false);
}
};
@@ -117,6 +237,15 @@ function KitchenOpsShell() {
}
/>
{session.error && }
+ {workflowState.sessionExpired && (
+
+ )}
);
}
@@ -151,95 +280,200 @@ function KitchenOpsShell() {
Kitchen Operations
- Protected kitchen board workflows for board visibility and priority updates.
+ Protected kitchen board workflows for board visibility, claim or release actions, transitions, and priority updates.
{session.error && }
- {globalError && }
+ {workflowState.error && (
+
+ Reauthenticate
+
+ ) : undefined
+ }
+ />
+ )}
-
-
- setContextId(event.target.value)}
- placeholder="Context Id"
- style={{ width: 280 }}
- />
-
+
+
+
+
+ setContextId(event.target.value)}
+ placeholder="Context Id"
+ style={{ width: 280 }}
+ />
+
+ } onClick={() => void loadBoard()} disabled={loadingBoard}>
+ Retry
+
+
+ {boardPayload ? (
+ <>
+
+ {boardPayload.contextId}
+ {boardPayload.summary}
+ {boardPayload.availableStations.join(', ')}
+
+ {boardPayload.lanes.map((lane) => (
+
+
+ pagination={false}
+ rowKey={(record) => record.workItemId}
+ dataSource={lane.items}
+ columns={boardColumns}
+ locale={{ emptyText: 'No work items in this lane.' }}
+ />
+
+ ))}
+ >
+ ) : (
+
+ )}
+
+ }>
{boardPayload ? (
-
- {boardPayload.contextId}
- {boardPayload.summary}
-
+
+ lane.items.map((item) => `${lane.lane}: ${item.workItemId} at ${item.station} is ${item.state}`)
+ )}
+ renderItem={(item) => {item}}
+ />
) : (
- No kitchen board payload loaded.
+
)}
-
-
+
+
}
/>
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-