feat(kitchen-ops-web): show shared kitchen progression
This commit is contained in:
parent
6136ad94a5
commit
6512e131b0
@ -25,8 +25,9 @@
|
|||||||
## UI Workflow Coverage
|
## UI Workflow Coverage
|
||||||
|
|
||||||
- Kitchen board lanes with work-item detail and station 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
|
- Claim, release, transition, and priority operator actions
|
||||||
- Latest operator result and recent action history
|
- Latest operator result and recent action history
|
||||||
- Session-expired handling with reauthentication guidance
|
- Session-expired handling with reauthentication guidance
|
||||||
- Protected route shell for board, operator actions, and session inspection
|
- Protected route shell for board, operator actions, and session inspection
|
||||||
|
- Kitchen transitions are presented as order progression toward floor handoff and payment eligibility.
|
||||||
|
|||||||
@ -26,6 +26,7 @@ npm run dev
|
|||||||
## Available Screens
|
## Available Screens
|
||||||
|
|
||||||
- `/board`: lane-based kitchen board and board event feed
|
- `/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
|
- `/actions`: claim, release, transition, and priority operator controls
|
||||||
- `/session`: current Thalos session profile payload
|
- `/session`: current Thalos session profile payload
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ npm run test:ci
|
|||||||
- `src/api/client.test.ts`: runtime-config precedence and fallback behavior.
|
- `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 for board and operator action flows.
|
||||||
- `src/auth/oidcLogin.test.ts`: OIDC start-url generation and safe return-url fallback.
|
- `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
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@ -67,7 +67,7 @@ describe('Kitchen Ops App', () => {
|
|||||||
summary: '2 active kitchen lanes',
|
summary: '2 active kitchen lanes',
|
||||||
lanes: [
|
lanes: [
|
||||||
{
|
{
|
||||||
lane: 'Cooking',
|
lane: 'Ready',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
workItemId: 'WORK-1',
|
workItemId: 'WORK-1',
|
||||||
@ -75,7 +75,7 @@ describe('Kitchen Ops App', () => {
|
|||||||
ticketId: 'TICK-1',
|
ticketId: 'TICK-1',
|
||||||
tableId: 'T-01',
|
tableId: 'T-01',
|
||||||
station: 'Grill',
|
station: 'Grill',
|
||||||
state: 'Cooking',
|
state: 'Ready',
|
||||||
priority: 2,
|
priority: 2,
|
||||||
claimedBy: 'chef-a',
|
claimedBy: 'chef-a',
|
||||||
etaMinutes: 12
|
etaMinutes: 12
|
||||||
@ -92,8 +92,9 @@ describe('Kitchen Ops App', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'Load Kitchen Board' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Load Kitchen Board' }));
|
||||||
|
|
||||||
await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context'));
|
await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context'));
|
||||||
expect(await screen.findByText('Lane: Cooking')).toBeInTheDocument();
|
expect(await screen.findByText('Lifecycle Stage: Ready')).toBeInTheDocument();
|
||||||
expect(await screen.findByText('Cooking: WORK-1 at Grill is Cooking')).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('runs operator actions from the actions route', async () => {
|
||||||
@ -114,7 +115,7 @@ describe('Kitchen Ops App', () => {
|
|||||||
orderId: 'ORD-1',
|
orderId: 'ORD-1',
|
||||||
ticketId: 'TICK-1',
|
ticketId: 'TICK-1',
|
||||||
previousState: 'Cooking',
|
previousState: 'Cooking',
|
||||||
currentState: 'Ready',
|
currentState: 'Served',
|
||||||
transitioned: true,
|
transitioned: true,
|
||||||
error: null
|
error: null
|
||||||
});
|
});
|
||||||
@ -130,10 +131,14 @@ describe('Kitchen Ops App', () => {
|
|||||||
expect(screen.getAllByText('chef-a').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('chef-a').length).toBeGreaterThan(0);
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('Order Id'), { target: { value: 'ORD-1' } });
|
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' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Transition Work Item' }));
|
||||||
|
|
||||||
await waitFor(() => expect(transitionKitchenWorkItem).toHaveBeenCalledTimes(1));
|
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 () => {
|
it('shows reauthentication guidance when board loading returns session expired', async () => {
|
||||||
|
|||||||
86
src/App.tsx
86
src/App.tsx
@ -78,7 +78,7 @@ const boardColumns = [
|
|||||||
{
|
{
|
||||||
title: 'State',
|
title: 'State',
|
||||||
dataIndex: '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: 'Priority', dataIndex: 'priority' },
|
||||||
{ title: 'Claimed By', render: (_: unknown, record: KitchenBoardItem) => record.claimedBy ?? 'Unclaimed' },
|
{ 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="Context Id">{boardPayload.contextId}</Descriptions.Item>
|
||||||
<Descriptions.Item label="Summary">{boardPayload.summary}</Descriptions.Item>
|
<Descriptions.Item label="Summary">{boardPayload.summary}</Descriptions.Item>
|
||||||
<Descriptions.Item label="Stations">{boardPayload.availableStations.join(', ')}</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>
|
</Descriptions>
|
||||||
{boardPayload.lanes.map((lane) => (
|
{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>
|
<Table<KitchenBoardItem>
|
||||||
pagination={false}
|
pagination={false}
|
||||||
rowKey={(record) => record.workItemId}
|
rowKey={(record) => record.workItemId}
|
||||||
@ -349,7 +358,10 @@ function KitchenOpsShell() {
|
|||||||
<List
|
<List
|
||||||
bordered
|
bordered
|
||||||
dataSource={boardPayload.lanes.flatMap((lane) =>
|
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>}
|
renderItem={(item) => <List.Item>{item}</List.Item>}
|
||||||
/>
|
/>
|
||||||
@ -407,9 +419,18 @@ function KitchenOpsShell() {
|
|||||||
<Card title="Transition Work Item State">
|
<Card title="Transition Work Item State">
|
||||||
<Form<TransitionKitchenWorkItemRequest>
|
<Form<TransitionKitchenWorkItemRequest>
|
||||||
layout="vertical"
|
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)}
|
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 }]}>
|
<Form.Item name="orderId" label="Order Id" rules={[{ required: true }]}>
|
||||||
<Input placeholder="Order Id" />
|
<Input placeholder="Order Id" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@ -523,9 +544,14 @@ function ActionDescriptions({ event }: { event: ActionEvent }) {
|
|||||||
<Descriptions bordered size="small" column={1}>
|
<Descriptions bordered size="small" column={1}>
|
||||||
<Descriptions.Item label="Order Id">{event.response.orderId}</Descriptions.Item>
|
<Descriptions.Item label="Order Id">{event.response.orderId}</Descriptions.Item>
|
||||||
<Descriptions.Item label="Ticket Id">{event.response.ticketId}</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="Previous State">
|
||||||
<Descriptions.Item label="Current State">{event.response.currentState}</Descriptions.Item>
|
<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="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.Item label="Error">{event.response.error ?? 'None'}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
);
|
);
|
||||||
@ -556,12 +582,58 @@ function getActionSummary(event: ActionEvent): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.kind === 'transition') {
|
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;
|
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 {
|
function providerLabel(provider: IdentityProvider): string {
|
||||||
if (provider === 0 || provider === '0' || provider === 'InternalJwt') {
|
if (provider === 0 || provider === '0' || provider === 'InternalJwt') {
|
||||||
return 'Internal JWT';
|
return 'Internal JWT';
|
||||||
|
|||||||
@ -57,6 +57,7 @@ export type TransitionKitchenWorkItemRequest = {
|
|||||||
ticketId: string;
|
ticketId: string;
|
||||||
targetState: string;
|
targetState: string;
|
||||||
updatedBy: string;
|
updatedBy: string;
|
||||||
|
contextId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TransitionKitchenWorkItemResponse = {
|
export type TransitionKitchenWorkItemResponse = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user