feat(kitchen-ops-web): show shared kitchen progression
This commit is contained in:
parent
6136ad94a5
commit
6512e131b0
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
86
src/App.tsx
86
src/App.tsx
@ -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';
|
||||
|
||||
@ -57,6 +57,7 @@ export type TransitionKitchenWorkItemRequest = {
|
||||
ticketId: string;
|
||||
targetState: string;
|
||||
updatedBy: string;
|
||||
contextId?: string;
|
||||
};
|
||||
|
||||
export type TransitionKitchenWorkItemResponse = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user