Compare commits

..

No commits in common. "development" and "feature/kitchen-ops-web-ant-protected-routes" have entirely different histories.

7 changed files with 94 additions and 708 deletions

View File

@ -5,7 +5,6 @@
- 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
@ -17,17 +16,10 @@
## 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 lanes with work-item detail and station coverage
- Board event feed derived from the loaded lane state and shared restaurant lifecycle progression
- 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
- Kitchen transitions are presented as order progression toward floor handoff and payment eligibility.
- Kitchen board lookup
- Kitchen priority updates
- Protected route shell for board, priority update, and session inspection

View File

@ -21,14 +21,6 @@ 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
- `/board`: lane-based kitchen board tied to restaurant order progression and handoff readiness
- `/actions`: claim, release, transition, and priority operator controls
- `/session`: current Thalos session profile payload
## Build

View File

@ -15,11 +15,10 @@ 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 for board and operator action flows.
- `src/api/dashboardApi.test.ts`: endpoint path/query composition and payload mapping.
- `src/auth/oidcLogin.test.ts`: OIDC start-url generation and safe return-url fallback.
- `src/App.test.tsx`: central login screen, lifecycle-aware board loading, operator actions, and session-expired reauthentication guidance.
- `src/App.test.tsx`: central login screen, protected board flow, and priority update workflow.
## Notes
- Use containerized Node execution when host `npm` is unavailable.
- Prefer container-first validation before opening or updating runtime stack images.

View File

@ -1,6 +1,5 @@
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(),
@ -10,30 +9,18 @@ vi.mock('./api/sessionApi', () => ({
}));
vi.mock('./api/dashboardApi', () => ({
claimKitchenWorkItem: vi.fn(),
loadDashboard: vi.fn(),
releaseKitchenWorkItem: vi.fn(),
setKitchenOrderPriority: vi.fn(),
transitionKitchenWorkItem: vi.fn()
setKitchenOrderPriority: vi.fn()
}));
import {
claimKitchenWorkItem,
loadDashboard,
releaseKitchenWorkItem,
setKitchenOrderPriority,
transitionKitchenWorkItem
} from './api/dashboardApi';
import { loadDashboard, setKitchenOrderPriority } 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',
@ -55,36 +42,14 @@ describe('Kitchen Ops App', () => {
expect(link.href).toContain('tenantId=demo-tenant');
});
it('loads kitchen board with lanes and board events for authenticated users', async () => {
it('loads kitchen board for authenticated users', async () => {
vi.mocked(getSessionMe).mockResolvedValue({
isAuthenticated: true,
subjectId: 'demo-user',
tenantId: 'demo-tenant',
provider: 2
});
vi.mocked(loadDashboard).mockResolvedValue({
contextId: 'demo-context',
summary: '2 active kitchen lanes',
lanes: [
{
lane: 'Ready',
items: [
{
workItemId: 'WORK-1',
orderId: 'ORD-1',
ticketId: 'TICK-1',
tableId: 'T-01',
station: 'Grill',
state: 'Ready',
priority: 2,
claimedBy: 'chef-a',
etaMinutes: 12
}
]
}
],
availableStations: ['Grill', 'Expo']
provider: 0
});
vi.mocked(loadDashboard).mockResolvedValue({ contextId: 'demo-context', summary: 'ready' });
render(<App />);
@ -92,77 +57,29 @@ describe('Kitchen Ops App', () => {
fireEvent.click(screen.getByRole('button', { name: 'Load Kitchen Board' }));
await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context'));
expect(await screen.findByText('Lifecycle Stage: Ready')).toBeInTheDocument();
expect(screen.getByText(/Kitchen lanes are a live view of restaurant orders/)).toBeInTheDocument();
expect(await screen.findByText('Ready: ORD-1 / TICK-1 at Grill is Ready and the order is ready for handoff to floor service')).toBeInTheDocument();
});
it('runs operator actions from the actions route', async () => {
it('updates kitchen priority from action route', async () => {
vi.mocked(getSessionMe).mockResolvedValue({
isAuthenticated: true,
subjectId: 'demo-user',
tenantId: 'demo-tenant',
provider: 2
provider: 0
});
vi.mocked(claimKitchenWorkItem).mockResolvedValue({
vi.mocked(setKitchenOrderPriority).mockResolvedValue({
contextId: 'demo-context',
workItemId: 'WORK-1',
claimed: true,
claimedBy: 'chef-a',
message: 'claimed'
});
vi.mocked(transitionKitchenWorkItem).mockResolvedValue({
orderId: 'ORD-1',
ticketId: 'TICK-1',
previousState: 'Cooking',
currentState: 'Served',
transitioned: true,
error: null
orderId: 'ORD-2200',
updated: true,
summary: 'updated'
});
render(<App />);
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(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(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.change(screen.getAllByPlaceholderText('Context Id')[2], { target: { value: 'demo-context' } });
fireEvent.click(screen.getByRole('button', { name: 'Transition Work Item' }));
await waitFor(() => expect(transitionKitchenWorkItem).toHaveBeenCalledTimes(1));
expect(transitionKitchenWorkItem).toHaveBeenCalledWith(
expect.objectContaining({ contextId: 'demo-context', orderId: 'ORD-1' })
);
expect(await screen.findByText('Cooking -> Served (the order is complete and the check can move to payment)')).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();
await waitFor(() => expect(setKitchenOrderPriority).toHaveBeenCalledTimes(1));
});
});

View File

@ -1,25 +1,15 @@
import {
ClockCircleOutlined,
DeploymentUnitOutlined,
OrderedListOutlined,
ReloadOutlined,
SyncOutlined,
ToolOutlined
} from '@ant-design/icons';
import { DeploymentUnitOutlined, OrderedListOutlined, ReloadOutlined, ToolOutlined } from '@ant-design/icons';
import {
Alert,
Button,
Card,
Descriptions,
Empty,
Form,
Input,
InputNumber,
Layout,
List,
Menu,
Result,
Select,
Space,
Spin,
Table,
@ -28,76 +18,25 @@ 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 TransitionKitchenWorkItemRequest,
type TransitionKitchenWorkItemResponse
type SetKitchenOrderPriorityResponse
} from './api/dashboardApi';
import type { IdentityProvider } from './api/sessionApi';
import { buildGoogleOidcStartUrl } from './auth/oidcLogin';
import { SessionProvider, useSessionContext } from './auth/sessionContext';
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 };
type AppRoute = '/board' | '/priority' | '/session';
const routeItems: Array<{ key: AppRoute; label: string; icon: ReactNode }> = [
{ key: '/board', label: 'Kitchen Board', icon: <OrderedListOutlined /> },
{ key: '/actions', label: 'Operator Actions', icon: <ToolOutlined /> },
{ key: '/priority', label: 'Set Priority', 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={workflowTagColor(value)}>{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>
@ -115,14 +54,11 @@ function KitchenOpsShell() {
const [contextId, setContextId] = useState('demo-context');
const [boardPayload, setBoardPayload] = useState<KitchenOpsBoardResponse | null>(null);
const [lastAction, setLastAction] = useState<ActionEvent | null>(null);
const [actionHistory, setActionHistory] = useState<ActionEvent[]>([]);
const [workflowState, setWorkflowState] = useState<WorkflowState>({ error: null, sessionExpired: false });
const [priorityResponse, setPriorityResponse] = useState<SetKitchenOrderPriorityResponse | null>(null);
const [priorityHistory, setPriorityHistory] = useState<SetKitchenOrderPriorityResponse[]>([]);
const [globalError, setGlobalError] = useState<string | null>(null);
const [loadingBoard, setLoadingBoard] = useState(false);
const [claimingItem, setClaimingItem] = useState(false);
const [releasingItem, setReleasingItem] = useState(false);
const [transitioningItem, setTransitioningItem] = useState(false);
const [updatingPriority, setUpdatingPriority] = useState(false);
const [submittingPriority, setSubmittingPriority] = useState(false);
const loginUrl = useMemo(() => buildGoogleOidcStartUrl(window.location.href), []);
const selectedKey = useMemo(() => {
@ -130,88 +66,32 @@ 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);
clearWorkflowError();
setGlobalError(null);
try {
const payload = await loadDashboard(contextId);
setBoardPayload(payload);
} catch (err) {
await handleWorkflowFailure(err, 'Failed to load kitchen board.');
setGlobalError(err instanceof Error ? err.message : 'Failed to load kitchen board.');
} finally {
setLoadingBoard(false);
}
};
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 updatePriority = async (request: SetKitchenOrderPriorityRequest) => {
setSubmittingPriority(true);
setGlobalError(null);
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);
pushActionHistory({ kind: 'priority', response: payload });
setPriorityResponse(payload);
// Keep recent responses bounded so the session view stays readable over long demos.
setPriorityHistory((previous) => [payload, ...previous].slice(0, 8));
} catch (err) {
await handleWorkflowFailure(err, 'Failed to update kitchen order priority.');
setGlobalError(err instanceof Error ? err.message : 'Failed to update kitchen order priority.');
} finally {
setUpdatingPriority(false);
setSubmittingPriority(false);
}
};
@ -237,15 +117,6 @@ 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>
);
}
@ -280,30 +151,15 @@ 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, claim or release actions, transitions, and priority updates.
Protected kitchen board workflows for board visibility and priority updates.
</Typography.Paragraph>
{session.error && <Alert className="stack-gap" type="warning" showIcon message={session.error} />}
{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
}
/>
)}
{globalError && <Alert className="stack-gap" type="error" showIcon message={globalError} />}
<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>
@ -316,117 +172,33 @@ 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.Item label="Lifecycle Note">
Kitchen lanes are a live view of restaurant orders moving from accepted work into preparation,
handoff readiness, and final service completion.
</Descriptions.Item>
</Descriptions>
{boardPayload.lanes.map((lane) => (
<Card
key={lane.lane}
type="inner"
title={`Lifecycle Stage: ${lane.lane}`}
extra={<Tag color={workflowTagColor(lane.lane)}>{lane.lane}</Tag>}
>
<Table<KitchenBoardItem>
pagination={false}
rowKey={(record) => record.workItemId}
dataSource={lane.items}
columns={boardColumns}
locale={{ emptyText: 'No work items in this lane.' }}
/>
</Card>
))}
</>
) : (
<Empty description="Load a context to inspect kitchen board lanes and stations." />
<Typography.Text type="secondary">No kitchen board payload loaded.</Typography.Text>
)}
</Space>
</Card>
<Card title="Board Events" extra={<ClockCircleOutlined />}>
{boardPayload ? (
<List
bordered
dataSource={boardPayload.lanes.flatMap((lane) =>
lane.items.map(
(item) =>
`${lane.lane}: ${item.orderId} / ${item.ticketId} at ${item.station} is ${item.state} and ${workflowProgressHint(item.state)}`
)
)}
renderItem={(item) => <List.Item>{item}</List.Item>}
/>
) : (
<Empty description="Board events populate after loading kitchen board data." />
)}
</Card>
</Space>
}
/>
<Route
path="/actions"
path="/priority"
element={
<Card title="Set Kitchen Priority">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card title="Claim Work Item">
<Form<ClaimKitchenWorkItemRequest>
layout="vertical"
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>
<Form<SetKitchenOrderPriorityRequest>
layout="vertical"
initialValues={{
contextId,
orderId: 'ORD-1001',
ticketId: 'TICK-1001',
targetState: 'Ready',
updatedBy: 'chef-a'
priority: 1,
updatedBy: 'kitchen-supervisor'
}}
onFinish={(values) => void executeTransition(values)}
onFinish={(values) => void updatePriority(values)}
>
<Form.Item name="contextId" label="Context Id" rules={[{ required: true }]}>
<Input placeholder="Context Id" />
@ -434,67 +206,40 @@ function KitchenOpsShell() {
<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={updatingPriority}>
<Button type="primary" htmlType="submit" loading={submittingPriority}>
Set Kitchen Priority
</Button>
</Form>
</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>
{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>
pagination={false}
rowKey={(record) => getActionEventKey(record)}
dataSource={actionHistory}
locale={{ emptyText: 'No recent kitchen actions yet.' }}
rowKey={(record) => `${record.contextId}-${record.orderId}`}
dataSource={priorityHistory}
columns={[
{ title: 'Action', render: (_, record) => <Tag>{record.kind}</Tag> },
{ title: 'Reference', render: (_, record) => getActionReference(record) },
{ title: 'Summary', render: (_, record) => getActionSummary(record) }
{ 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' }
]}
/>
</Card>
</Space>
</Card>
}
/>
<Route
@ -514,126 +259,6 @@ 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">
<Tag color={workflowTagColor(event.response.previousState)}>{event.response.previousState}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Current State">
<Tag color={workflowTagColor(event.response.currentState)}>{event.response.currentState}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Transitioned">{String(event.response.transitioned)}</Descriptions.Item>
<Descriptions.Item label="Next Step">{workflowProgressHint(event.response.currentState)}</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} (${workflowProgressHint(event.response.currentState)})`;
}
return event.response.summary;
}
function workflowTagColor(state: string): string {
switch (state.toLowerCase()) {
case 'queued':
case 'accepted':
return 'blue';
case 'cooking':
case 'preparing':
return 'orange';
case 'ready':
case 'readyforpickup':
return 'cyan';
case 'served':
case 'delivered':
return 'green';
case 'paid':
return 'purple';
case 'blocked':
case 'failed':
case 'canceled':
return 'red';
default:
return 'default';
}
}
function workflowProgressHint(state: string): string {
switch (state.toLowerCase()) {
case 'queued':
case 'accepted':
return 'waiting for a kitchen operator to start preparation';
case 'cooking':
case 'preparing':
return 'the kitchen is actively preparing this order';
case 'ready':
case 'readyforpickup':
return 'the order is ready for handoff to floor service';
case 'served':
case 'delivered':
return 'the order is complete and the check can move to payment';
case 'paid':
return 'the restaurant workflow is fully closed';
default:
return 'the kitchen workflow is still progressing';
}
}
function providerLabel(provider: IdentityProvider): string {
if (provider === 0 || provider === '0' || provider === 'InternalJwt') {
return 'Internal JWT';

View File

@ -6,13 +6,7 @@ vi.mock('./client', () => ({
}));
import { getJson, postJson } from './client';
import {
claimKitchenWorkItem,
loadDashboard,
releaseKitchenWorkItem,
setKitchenOrderPriority,
transitionKitchenWorkItem
} from './dashboardApi';
import { loadDashboard, setKitchenOrderPriority } from './dashboardApi';
describe('kitchen ops dashboard api', () => {
it('builds encoded board endpoint path', async () => {
@ -23,69 +17,19 @@ 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',
workItemId: 'WORK-1',
orderId: 'ORD-1',
priority: 2,
updatedBy: 'chef'
});
expect(postJson).toHaveBeenCalledWith('/api/kitchen/ops/board/priority', {
contextId: 'ctx',
workItemId: 'WORK-1',
orderId: 'ORD-1',
priority: 2,
updatedBy: 'chef'
});

View File

@ -1,86 +1,21 @@
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;
contextId?: string;
};
export type TransitionKitchenWorkItemResponse = {
orderId: string;
ticketId: string;
previousState: string;
currentState: string;
transitioned: boolean;
error: string | null;
};
export type SetKitchenOrderPriorityRequest = {
contextId: string;
workItemId: string;
orderId: string;
priority: number;
updatedBy: string;
};
export type SetKitchenOrderPriorityResponse = {
contextId: string;
workItemId: string;
orderId: string;
updated: boolean;
priority: number;
summary: string;
};
@ -88,24 +23,6 @@ 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> {