feat(kitchen-ops-web): deepen kitchen operator workflows

This commit is contained in:
José René White Enciso 2026-03-31 17:16:58 -06:00
parent 143734098e
commit 6136ad94a5
7 changed files with 631 additions and 97 deletions

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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(<App />);
@ -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(<App />);
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(<App />);
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();
});
});

View File

@ -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: <OrderedListOutlined /> },
{ key: '/priority', label: 'Set Priority', icon: <ToolOutlined /> },
{ key: '/actions', label: 'Operator Actions', icon: <ToolOutlined /> },
{ key: '/session', label: 'Session', icon: <DeploymentUnitOutlined /> }
];
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) => <Tag color={value === 'Ready' ? 'green' : value === 'Cooking' ? 'orange' : 'blue'}>{value}</Tag>
},
{ 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 (
<SessionProvider>
@ -54,11 +115,14 @@ function KitchenOpsShell() {
const [contextId, setContextId] = useState('demo-context');
const [boardPayload, setBoardPayload] = useState<KitchenOpsBoardResponse | null>(null);
const [priorityResponse, setPriorityResponse] = useState<SetKitchenOrderPriorityResponse | null>(null);
const [priorityHistory, setPriorityHistory] = useState<SetKitchenOrderPriorityResponse[]>([]);
const [globalError, setGlobalError] = useState<string | null>(null);
const [lastAction, setLastAction] = useState<ActionEvent | null>(null);
const [actionHistory, setActionHistory] = useState<ActionEvent[]>([]);
const [workflowState, setWorkflowState] = useState<WorkflowState>({ 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 && <Alert type="error" showIcon message={session.error} />}
{workflowState.sessionExpired && (
<Alert
className="stack-gap"
type="warning"
showIcon
message="Session expired"
description="Your last kitchen workflow action returned 401, so the app is asking you to sign in again."
/>
)}
</main>
);
}
@ -151,15 +280,30 @@ function KitchenOpsShell() {
<Layout.Content className="content">
<Typography.Title level={3}>Kitchen Operations</Typography.Title>
<Typography.Paragraph type="secondary">
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.
</Typography.Paragraph>
{session.error && <Alert className="stack-gap" type="warning" showIcon message={session.error} />}
{globalError && <Alert className="stack-gap" type="error" showIcon message={globalError} />}
{workflowState.error && (
<Alert
className="stack-gap"
type={workflowState.sessionExpired ? 'warning' : 'error'}
showIcon
message={workflowState.error}
action={
workflowState.sessionExpired ? (
<Button size="small" type="primary" href={loginUrl}>
Reauthenticate
</Button>
) : undefined
}
/>
)}
<Routes>
<Route
path="/board"
element={
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card title="Kitchen Board">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space wrap>
@ -172,74 +316,164 @@ function KitchenOpsShell() {
<Button type="primary" loading={loadingBoard} onClick={() => void loadBoard()}>
Load Kitchen Board
</Button>
<Button icon={<SyncOutlined />} onClick={() => void loadBoard()} disabled={loadingBoard}>
Retry
</Button>
</Space>
{boardPayload ? (
<>
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Context Id">{boardPayload.contextId}</Descriptions.Item>
<Descriptions.Item label="Summary">{boardPayload.summary}</Descriptions.Item>
<Descriptions.Item label="Stations">{boardPayload.availableStations.join(', ')}</Descriptions.Item>
</Descriptions>
{boardPayload.lanes.map((lane) => (
<Card key={lane.lane} type="inner" title={`Lane: ${lane.lane}`}>
<Table<KitchenBoardItem>
pagination={false}
rowKey={(record) => record.workItemId}
dataSource={lane.items}
columns={boardColumns}
locale={{ emptyText: 'No work items in this lane.' }}
/>
</Card>
))}
</>
) : (
<Typography.Text type="secondary">No kitchen board payload loaded.</Typography.Text>
<Empty description="Load a context to inspect kitchen board lanes and stations." />
)}
</Space>
</Card>
<Card title="Board Events" extra={<ClockCircleOutlined />}>
{boardPayload ? (
<List
bordered
dataSource={boardPayload.lanes.flatMap((lane) =>
lane.items.map((item) => `${lane.lane}: ${item.workItemId} at ${item.station} is ${item.state}`)
)}
renderItem={(item) => <List.Item>{item}</List.Item>}
/>
) : (
<Empty description="Board events populate after loading kitchen board data." />
)}
</Card>
</Space>
}
/>
<Route
path="/priority"
path="/actions"
element={
<Card title="Set Kitchen Priority">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Form<SetKitchenOrderPriorityRequest>
<Card title="Claim Work Item">
<Form<ClaimKitchenWorkItemRequest>
layout="vertical"
initialValues={{
contextId,
orderId: 'ORD-1001',
priority: 1,
updatedBy: 'kitchen-supervisor'
}}
onFinish={(values) => void updatePriority(values)}
initialValues={{ contextId, workItemId: 'WORK-1001', claimedBy: 'chef-a' }}
onFinish={(values) => void executeClaim(values)}
>
<Form.Item name="contextId" label="Context Id" rules={[{ required: true }]}>
<Input placeholder="Context Id" />
</Form.Item>
<Form.Item name="workItemId" label="Work Item Id" rules={[{ required: true }]}>
<Input placeholder="Work Item Id" />
</Form.Item>
<Form.Item name="claimedBy" label="Claimed By" rules={[{ required: true }]}>
<Input placeholder="Operator Id" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={claimingItem}>
Claim Work Item
</Button>
</Form>
</Card>
<Card title="Release Work Item">
<Form<ReleaseKitchenWorkItemRequest>
layout="vertical"
initialValues={{ contextId, workItemId: 'WORK-1001', releasedBy: 'chef-a' }}
onFinish={(values) => void executeRelease(values)}
>
<Form.Item name="contextId" label="Context Id" rules={[{ required: true }]}>
<Input placeholder="Context Id" />
</Form.Item>
<Form.Item name="workItemId" label="Work Item Id" rules={[{ required: true }]}>
<Input placeholder="Work Item Id" />
</Form.Item>
<Form.Item name="releasedBy" label="Released By" rules={[{ required: true }]}>
<Input placeholder="Operator Id" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={releasingItem}>
Release Work Item
</Button>
</Form>
</Card>
<Card title="Transition Work Item State">
<Form<TransitionKitchenWorkItemRequest>
layout="vertical"
initialValues={{ orderId: 'ORD-1001', ticketId: 'TICK-1001', targetState: 'Ready', updatedBy: 'chef-a' }}
onFinish={(values) => void executeTransition(values)}
>
<Form.Item name="orderId" label="Order Id" rules={[{ required: true }]}>
<Input placeholder="Order Id" />
</Form.Item>
<Form.Item name="ticketId" label="Ticket Id" rules={[{ required: true }]}>
<Input placeholder="Ticket Id" />
</Form.Item>
<Form.Item name="targetState" label="Target State" rules={[{ required: true }]}>
<Select
options={[
{ label: 'Queued', value: 'Queued' },
{ label: 'Cooking', value: 'Cooking' },
{ label: 'Ready', value: 'Ready' },
{ label: 'Served', value: 'Served' }
]}
/>
</Form.Item>
<Form.Item name="updatedBy" label="Updated By" rules={[{ required: true }]}>
<Input placeholder="Operator Id" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={transitioningItem}>
Transition Work Item
</Button>
</Form>
</Card>
<Card title="Set Kitchen Priority">
<Form<SetKitchenOrderPriorityRequest>
layout="vertical"
initialValues={{ contextId, workItemId: 'WORK-1001', priority: 1, updatedBy: 'kitchen-supervisor' }}
onFinish={(values) => void executePriorityUpdate(values)}
>
<Form.Item name="contextId" label="Context Id" rules={[{ required: true }]}>
<Input placeholder="Context Id" />
</Form.Item>
<Form.Item name="workItemId" label="Work Item Id" rules={[{ required: true }]}>
<Input placeholder="Work Item Id" />
</Form.Item>
<Form.Item name="priority" label="Priority" rules={[{ required: true, type: 'number', min: 0 }]}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="updatedBy" label="Updated By" rules={[{ required: true }]}>
<Input placeholder="Operator Id" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={submittingPriority}>
<Button type="primary" htmlType="submit" loading={updatingPriority}>
Set Kitchen Priority
</Button>
</Form>
{priorityResponse && (
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Context Id">{priorityResponse.contextId}</Descriptions.Item>
<Descriptions.Item label="Order Id">{priorityResponse.orderId}</Descriptions.Item>
<Descriptions.Item label="Updated">{String(priorityResponse.updated)}</Descriptions.Item>
<Descriptions.Item label="Summary">{priorityResponse.summary}</Descriptions.Item>
</Descriptions>
)}
<Table<SetKitchenOrderPriorityResponse>
</Card>
<Card title="Latest Operator Result">
{lastAction ? <ActionDescriptions event={lastAction} /> : <Empty description="Run a kitchen action to inspect the latest response." />}
</Card>
<Card title="Recent Operator Actions">
<Table<ActionEvent>
pagination={false}
rowKey={(record) => `${record.contextId}-${record.orderId}`}
dataSource={priorityHistory}
rowKey={(record) => getActionEventKey(record)}
dataSource={actionHistory}
locale={{ emptyText: 'No recent kitchen actions yet.' }}
columns={[
{ title: 'Order Id', dataIndex: 'orderId' },
{ title: 'Context Id', dataIndex: 'contextId' },
{
title: 'Updated',
render: (_, record) => <Tag color={record.updated ? 'green' : 'red'}>{String(record.updated)}</Tag>
},
{ title: 'Summary', dataIndex: 'summary' }
{ title: 'Action', render: (_, record) => <Tag>{record.kind}</Tag> },
{ title: 'Reference', render: (_, record) => getActionReference(record) },
{ title: 'Summary', render: (_, record) => getActionSummary(record) }
]}
/>
</Space>
</Card>
</Space>
}
/>
<Route
@ -259,6 +493,75 @@ function KitchenOpsShell() {
);
}
function ActionDescriptions({ event }: { event: ActionEvent }) {
if (event.kind === 'claim') {
return (
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Context Id">{event.response.contextId}</Descriptions.Item>
<Descriptions.Item label="Work Item Id">{event.response.workItemId}</Descriptions.Item>
<Descriptions.Item label="Claimed">{String(event.response.claimed)}</Descriptions.Item>
<Descriptions.Item label="Claimed By">{event.response.claimedBy}</Descriptions.Item>
<Descriptions.Item label="Message">{event.response.message}</Descriptions.Item>
</Descriptions>
);
}
if (event.kind === 'release') {
return (
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Context Id">{event.response.contextId}</Descriptions.Item>
<Descriptions.Item label="Work Item Id">{event.response.workItemId}</Descriptions.Item>
<Descriptions.Item label="Released">{String(event.response.released)}</Descriptions.Item>
<Descriptions.Item label="Released By">{event.response.releasedBy}</Descriptions.Item>
<Descriptions.Item label="Message">{event.response.message}</Descriptions.Item>
</Descriptions>
);
}
if (event.kind === 'transition') {
return (
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Order Id">{event.response.orderId}</Descriptions.Item>
<Descriptions.Item label="Ticket Id">{event.response.ticketId}</Descriptions.Item>
<Descriptions.Item label="Previous State">{event.response.previousState}</Descriptions.Item>
<Descriptions.Item label="Current State">{event.response.currentState}</Descriptions.Item>
<Descriptions.Item label="Transitioned">{String(event.response.transitioned)}</Descriptions.Item>
<Descriptions.Item label="Error">{event.response.error ?? 'None'}</Descriptions.Item>
</Descriptions>
);
}
return (
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Context Id">{event.response.contextId}</Descriptions.Item>
<Descriptions.Item label="Work Item Id">{event.response.workItemId}</Descriptions.Item>
<Descriptions.Item label="Updated">{String(event.response.updated)}</Descriptions.Item>
<Descriptions.Item label="Priority">{event.response.priority}</Descriptions.Item>
<Descriptions.Item label="Summary">{event.response.summary}</Descriptions.Item>
</Descriptions>
);
}
function getActionReference(event: ActionEvent): string {
if (event.kind === 'transition') {
return `${event.response.orderId} / ${event.response.ticketId}`;
}
return event.response.workItemId;
}
function getActionSummary(event: ActionEvent): string {
if (event.kind === 'claim' || event.kind === 'release') {
return event.response.message;
}
if (event.kind === 'transition') {
return `${event.response.previousState} -> ${event.response.currentState}`;
}
return event.response.summary;
}
function providerLabel(provider: IdentityProvider): string {
if (provider === 0 || provider === '0' || provider === 'InternalJwt') {
return 'Internal JWT';

View File

@ -6,7 +6,13 @@ vi.mock('./client', () => ({
}));
import { getJson, postJson } from './client';
import { loadDashboard, setKitchenOrderPriority } from './dashboardApi';
import {
claimKitchenWorkItem,
loadDashboard,
releaseKitchenWorkItem,
setKitchenOrderPriority,
transitionKitchenWorkItem
} from './dashboardApi';
describe('kitchen ops dashboard api', () => {
it('builds encoded board endpoint path', async () => {
@ -17,19 +23,69 @@ describe('kitchen ops dashboard api', () => {
expect(getJson).toHaveBeenCalledWith('/api/kitchen/ops/board?contextId=ctx%20kitchen%2F1');
});
it('posts claim payload', async () => {
vi.mocked(postJson).mockResolvedValue({ claimed: true });
await claimKitchenWorkItem({
contextId: 'ctx',
workItemId: 'WORK-1',
claimedBy: 'chef'
});
expect(postJson).toHaveBeenCalledWith('/api/kitchen/ops/work-items/claim', {
contextId: 'ctx',
workItemId: 'WORK-1',
claimedBy: 'chef'
});
});
it('posts release payload', async () => {
vi.mocked(postJson).mockResolvedValue({ released: true });
await releaseKitchenWorkItem({
contextId: 'ctx',
workItemId: 'WORK-1',
releasedBy: 'chef'
});
expect(postJson).toHaveBeenCalledWith('/api/kitchen/ops/work-items/release', {
contextId: 'ctx',
workItemId: 'WORK-1',
releasedBy: 'chef'
});
});
it('posts transition payload', async () => {
vi.mocked(postJson).mockResolvedValue({ transitioned: true });
await transitionKitchenWorkItem({
orderId: 'ORD-1',
ticketId: 'TICK-1',
targetState: 'Ready',
updatedBy: 'chef'
});
expect(postJson).toHaveBeenCalledWith('/api/kitchen/ops/work-items/transition', {
orderId: 'ORD-1',
ticketId: 'TICK-1',
targetState: 'Ready',
updatedBy: 'chef'
});
});
it('posts priority update payload', async () => {
vi.mocked(postJson).mockResolvedValue({ updated: true });
await setKitchenOrderPriority({
contextId: 'ctx',
orderId: 'ORD-1',
workItemId: 'WORK-1',
priority: 2,
updatedBy: 'chef'
});
expect(postJson).toHaveBeenCalledWith('/api/kitchen/ops/board/priority', {
contextId: 'ctx',
orderId: 'ORD-1',
workItemId: 'WORK-1',
priority: 2,
updatedBy: 'chef'
});

View File

@ -1,21 +1,85 @@
import { getJson, postJson } from './client';
export type KitchenBoardItem = {
workItemId: string;
orderId: string;
ticketId: string;
tableId: string;
station: string;
state: string;
priority: number;
claimedBy: string | null;
etaMinutes: number;
};
export type KitchenBoardLane = {
lane: string;
items: KitchenBoardItem[];
};
export type KitchenOpsBoardResponse = {
contextId: string;
summary: string;
lanes: KitchenBoardLane[];
availableStations: string[];
};
export type ClaimKitchenWorkItemRequest = {
contextId: string;
workItemId: string;
claimedBy: string;
};
export type ClaimKitchenWorkItemResponse = {
contextId: string;
workItemId: string;
claimed: boolean;
claimedBy: string;
message: string;
};
export type ReleaseKitchenWorkItemRequest = {
contextId: string;
workItemId: string;
releasedBy: string;
};
export type ReleaseKitchenWorkItemResponse = {
contextId: string;
workItemId: string;
released: boolean;
releasedBy: string;
message: string;
};
export type TransitionKitchenWorkItemRequest = {
orderId: string;
ticketId: string;
targetState: string;
updatedBy: string;
};
export type TransitionKitchenWorkItemResponse = {
orderId: string;
ticketId: string;
previousState: string;
currentState: string;
transitioned: boolean;
error: string | null;
};
export type SetKitchenOrderPriorityRequest = {
contextId: string;
orderId: string;
workItemId: string;
priority: number;
updatedBy: string;
};
export type SetKitchenOrderPriorityResponse = {
contextId: string;
orderId: string;
workItemId: string;
updated: boolean;
priority: number;
summary: string;
};
@ -23,6 +87,24 @@ export async function loadDashboard(contextId: string): Promise<KitchenOpsBoardR
return getJson<KitchenOpsBoardResponse>(`/api/kitchen/ops/board?contextId=${encodeURIComponent(contextId)}`);
}
export async function claimKitchenWorkItem(
request: ClaimKitchenWorkItemRequest
): Promise<ClaimKitchenWorkItemResponse> {
return postJson<ClaimKitchenWorkItemResponse>('/api/kitchen/ops/work-items/claim', request);
}
export async function releaseKitchenWorkItem(
request: ReleaseKitchenWorkItemRequest
): Promise<ReleaseKitchenWorkItemResponse> {
return postJson<ReleaseKitchenWorkItemResponse>('/api/kitchen/ops/work-items/release', request);
}
export async function transitionKitchenWorkItem(
request: TransitionKitchenWorkItemRequest
): Promise<TransitionKitchenWorkItemResponse> {
return postJson<TransitionKitchenWorkItemResponse>('/api/kitchen/ops/work-items/transition', request);
}
export async function setKitchenOrderPriority(
request: SetKitchenOrderPriorityRequest
): Promise<SetKitchenOrderPriorityResponse> {