feat(kitchen-ops-web): show shared kitchen progression

This commit is contained in:
José René White Enciso 2026-03-31 19:06:17 -06:00
parent 6136ad94a5
commit 6512e131b0
6 changed files with 95 additions and 15 deletions

View File

@ -25,8 +25,9 @@
## UI Workflow Coverage
- Kitchen board lanes with work-item detail and station coverage
- Board event feed derived from the loaded lane state
- 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.

View File

@ -26,6 +26,7 @@ npm run dev
## 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

View File

@ -17,7 +17,7 @@ npm run test:ci
- `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/auth/oidcLogin.test.ts`: OIDC start-url generation and safe return-url fallback.
- `src/App.test.tsx`: central login screen, board lane loading, operator actions, and session-expired reauthentication guidance.
- `src/App.test.tsx`: central login screen, lifecycle-aware board loading, operator actions, and session-expired reauthentication guidance.
## Notes

View File

@ -67,7 +67,7 @@ describe('Kitchen Ops App', () => {
summary: '2 active kitchen lanes',
lanes: [
{
lane: 'Cooking',
lane: 'Ready',
items: [
{
workItemId: 'WORK-1',
@ -75,7 +75,7 @@ describe('Kitchen Ops App', () => {
ticketId: 'TICK-1',
tableId: 'T-01',
station: 'Grill',
state: 'Cooking',
state: 'Ready',
priority: 2,
claimedBy: 'chef-a',
etaMinutes: 12
@ -92,8 +92,9 @@ 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();
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 () => {
@ -114,7 +115,7 @@ describe('Kitchen Ops App', () => {
orderId: 'ORD-1',
ticketId: 'TICK-1',
previousState: 'Cooking',
currentState: 'Ready',
currentState: 'Served',
transitioned: true,
error: null
});
@ -130,10 +131,14 @@ describe('Kitchen Ops App', () => {
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(await screen.findByText('Cooking -> Ready')).toBeInTheDocument();
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 () => {

View File

@ -78,7 +78,7 @@ const boardColumns = [
{
title: 'State',
dataIndex: 'state',
render: (value: string) => <Tag color={value === 'Ready' ? 'green' : value === 'Cooking' ? 'orange' : 'blue'}>{value}</Tag>
render: (value: string) => <Tag color={workflowTagColor(value)}>{value}</Tag>
},
{ title: 'Priority', dataIndex: 'priority' },
{ title: 'Claimed By', render: (_: unknown, record: KitchenBoardItem) => record.claimedBy ?? 'Unclaimed' },
@ -326,9 +326,18 @@ function KitchenOpsShell() {
<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={`Lane: ${lane.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}
@ -349,7 +358,10 @@ function KitchenOpsShell() {
<List
bordered
dataSource={boardPayload.lanes.flatMap((lane) =>
lane.items.map((item) => `${lane.lane}: ${item.workItemId} at ${item.station} is ${item.state}`)
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>}
/>
@ -407,9 +419,18 @@ function KitchenOpsShell() {
<Card title="Transition Work Item State">
<Form<TransitionKitchenWorkItemRequest>
layout="vertical"
initialValues={{ orderId: 'ORD-1001', ticketId: 'TICK-1001', targetState: 'Ready', updatedBy: 'chef-a' }}
initialValues={{
contextId,
orderId: 'ORD-1001',
ticketId: 'TICK-1001',
targetState: 'Ready',
updatedBy: 'chef-a'
}}
onFinish={(values) => void executeTransition(values)}
>
<Form.Item name="contextId" label="Context Id" rules={[{ required: true }]}>
<Input placeholder="Context Id" />
</Form.Item>
<Form.Item name="orderId" label="Order Id" rules={[{ required: true }]}>
<Input placeholder="Order Id" />
</Form.Item>
@ -523,9 +544,14 @@ function ActionDescriptions({ event }: { event: ActionEvent }) {
<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="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>
);
@ -556,12 +582,58 @@ function getActionSummary(event: ActionEvent): string {
}
if (event.kind === 'transition') {
return `${event.response.previousState} -> ${event.response.currentState}`;
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

@ -57,6 +57,7 @@ export type TransitionKitchenWorkItemRequest = {
ticketId: string;
targetState: string;
updatedBy: string;
contextId?: string;
};
export type TransitionKitchenWorkItemResponse = {