feat(kitchen-ops-web): deepen kitchen operator workflows
This commit is contained in:
parent
143734098e
commit
6136ad94a5
@ -5,6 +5,7 @@
|
|||||||
- The UI does not access DAL or internal services directly.
|
- The UI does not access DAL or internal services directly.
|
||||||
- Route shell uses Ant Design layout/menu and keeps business views behind session checks.
|
- 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.
|
- 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
|
## Runtime Base URLs
|
||||||
|
|
||||||
@ -16,10 +17,16 @@
|
|||||||
## Protected Workflow Endpoints
|
## Protected Workflow Endpoints
|
||||||
|
|
||||||
- `GET /api/kitchen/ops/board?contextId=...`
|
- `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`
|
- `POST /api/kitchen/ops/board/priority`
|
||||||
|
|
||||||
## UI Workflow Coverage
|
## UI Workflow Coverage
|
||||||
|
|
||||||
- Kitchen board lookup
|
- Kitchen board lanes with work-item detail and station coverage
|
||||||
- Kitchen priority updates
|
- Board event feed derived from the loaded lane state
|
||||||
- Protected route shell for board, priority update, and session inspection
|
- 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
|
||||||
|
|||||||
@ -21,6 +21,13 @@ npm run dev
|
|||||||
- Login is executed via central Thalos OIDC start endpoint.
|
- Login is executed via central Thalos OIDC start endpoint.
|
||||||
- Business calls are gated behind session checks.
|
- Business calls are gated behind session checks.
|
||||||
- Session cookies are sent with `credentials: include`.
|
- 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
|
## Build
|
||||||
|
|
||||||
|
|||||||
@ -15,10 +15,11 @@ npm run test:ci
|
|||||||
## Coverage Scope
|
## Coverage Scope
|
||||||
|
|
||||||
- `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 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/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
|
## Notes
|
||||||
|
|
||||||
- Use containerized Node execution when host `npm` is unavailable.
|
- Use containerized Node execution when host `npm` is unavailable.
|
||||||
|
- Prefer container-first validation before opening or updating runtime stack images.
|
||||||
|
|||||||
110
src/App.test.tsx
110
src/App.test.tsx
@ -1,5 +1,6 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ApiError } from './api/client';
|
||||||
|
|
||||||
vi.mock('./api/sessionApi', () => ({
|
vi.mock('./api/sessionApi', () => ({
|
||||||
getSessionMe: vi.fn(),
|
getSessionMe: vi.fn(),
|
||||||
@ -9,18 +10,30 @@ vi.mock('./api/sessionApi', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./api/dashboardApi', () => ({
|
vi.mock('./api/dashboardApi', () => ({
|
||||||
|
claimKitchenWorkItem: vi.fn(),
|
||||||
loadDashboard: 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 { getSessionMe } from './api/sessionApi';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
describe('Kitchen Ops App', () => {
|
describe('Kitchen Ops App', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.mocked(claimKitchenWorkItem).mockReset();
|
||||||
vi.mocked(loadDashboard).mockReset();
|
vi.mocked(loadDashboard).mockReset();
|
||||||
|
vi.mocked(releaseKitchenWorkItem).mockReset();
|
||||||
vi.mocked(setKitchenOrderPriority).mockReset();
|
vi.mocked(setKitchenOrderPriority).mockReset();
|
||||||
|
vi.mocked(transitionKitchenWorkItem).mockReset();
|
||||||
vi.mocked(getSessionMe).mockReset();
|
vi.mocked(getSessionMe).mockReset();
|
||||||
window.__APP_CONFIG__ = {
|
window.__APP_CONFIG__ = {
|
||||||
API_BASE_URL: 'http://localhost:8080',
|
API_BASE_URL: 'http://localhost:8080',
|
||||||
@ -42,14 +55,36 @@ describe('Kitchen Ops App', () => {
|
|||||||
expect(link.href).toContain('tenantId=demo-tenant');
|
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({
|
vi.mocked(getSessionMe).mockResolvedValue({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
subjectId: 'demo-user',
|
subjectId: 'demo-user',
|
||||||
tenantId: 'demo-tenant',
|
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 />);
|
render(<App />);
|
||||||
|
|
||||||
@ -57,29 +92,72 @@ 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('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({
|
vi.mocked(getSessionMe).mockResolvedValue({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
subjectId: 'demo-user',
|
subjectId: 'demo-user',
|
||||||
tenantId: 'demo-tenant',
|
tenantId: 'demo-tenant',
|
||||||
provider: 0
|
provider: 2
|
||||||
});
|
});
|
||||||
vi.mocked(setKitchenOrderPriority).mockResolvedValue({
|
vi.mocked(claimKitchenWorkItem).mockResolvedValue({
|
||||||
contextId: 'demo-context',
|
contextId: 'demo-context',
|
||||||
orderId: 'ORD-2200',
|
workItemId: 'WORK-1',
|
||||||
updated: true,
|
claimed: true,
|
||||||
summary: 'updated'
|
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 />);
|
render(<App />);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('Set Priority')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Operator Actions')).toBeInTheDocument());
|
||||||
fireEvent.click(screen.getByText('Set Priority'));
|
fireEvent.click(screen.getByText('Operator Actions'));
|
||||||
fireEvent.change(screen.getByPlaceholderText('Order Id'), { target: { value: 'ORD-2200' } });
|
fireEvent.click(screen.getByRole('button', { name: 'Claim Work Item' }));
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Set Kitchen Priority' }));
|
|
||||||
|
|
||||||
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
405
src/App.tsx
405
src/App.tsx
@ -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 {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
|
Empty,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Layout,
|
Layout,
|
||||||
|
List,
|
||||||
Menu,
|
Menu,
|
||||||
Result,
|
Result,
|
||||||
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
Table,
|
Table,
|
||||||
@ -18,25 +28,76 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { type ReactNode, useMemo, useState } from 'react';
|
import { type ReactNode, useMemo, useState } from 'react';
|
||||||
import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { ApiError } from './api/client';
|
||||||
import {
|
import {
|
||||||
|
claimKitchenWorkItem,
|
||||||
loadDashboard,
|
loadDashboard,
|
||||||
|
releaseKitchenWorkItem,
|
||||||
setKitchenOrderPriority,
|
setKitchenOrderPriority,
|
||||||
|
transitionKitchenWorkItem,
|
||||||
|
type ClaimKitchenWorkItemRequest,
|
||||||
|
type ClaimKitchenWorkItemResponse,
|
||||||
|
type KitchenBoardItem,
|
||||||
type KitchenOpsBoardResponse,
|
type KitchenOpsBoardResponse,
|
||||||
|
type ReleaseKitchenWorkItemRequest,
|
||||||
|
type ReleaseKitchenWorkItemResponse,
|
||||||
type SetKitchenOrderPriorityRequest,
|
type SetKitchenOrderPriorityRequest,
|
||||||
type SetKitchenOrderPriorityResponse
|
type SetKitchenOrderPriorityResponse,
|
||||||
|
type TransitionKitchenWorkItemRequest,
|
||||||
|
type TransitionKitchenWorkItemResponse
|
||||||
} from './api/dashboardApi';
|
} from './api/dashboardApi';
|
||||||
import type { IdentityProvider } from './api/sessionApi';
|
import type { IdentityProvider } from './api/sessionApi';
|
||||||
import { buildGoogleOidcStartUrl } from './auth/oidcLogin';
|
import { buildGoogleOidcStartUrl } from './auth/oidcLogin';
|
||||||
import { SessionProvider, useSessionContext } from './auth/sessionContext';
|
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 }> = [
|
const routeItems: Array<{ key: AppRoute; label: string; icon: ReactNode }> = [
|
||||||
{ key: '/board', label: 'Kitchen Board', icon: <OrderedListOutlined /> },
|
{ 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 /> }
|
{ 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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
@ -54,11 +115,14 @@ function KitchenOpsShell() {
|
|||||||
|
|
||||||
const [contextId, setContextId] = useState('demo-context');
|
const [contextId, setContextId] = useState('demo-context');
|
||||||
const [boardPayload, setBoardPayload] = useState<KitchenOpsBoardResponse | null>(null);
|
const [boardPayload, setBoardPayload] = useState<KitchenOpsBoardResponse | null>(null);
|
||||||
const [priorityResponse, setPriorityResponse] = useState<SetKitchenOrderPriorityResponse | null>(null);
|
const [lastAction, setLastAction] = useState<ActionEvent | null>(null);
|
||||||
const [priorityHistory, setPriorityHistory] = useState<SetKitchenOrderPriorityResponse[]>([]);
|
const [actionHistory, setActionHistory] = useState<ActionEvent[]>([]);
|
||||||
const [globalError, setGlobalError] = useState<string | null>(null);
|
const [workflowState, setWorkflowState] = useState<WorkflowState>({ error: null, sessionExpired: false });
|
||||||
const [loadingBoard, setLoadingBoard] = useState(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 loginUrl = useMemo(() => buildGoogleOidcStartUrl(window.location.href), []);
|
||||||
const selectedKey = useMemo(() => {
|
const selectedKey = useMemo(() => {
|
||||||
@ -66,32 +130,88 @@ function KitchenOpsShell() {
|
|||||||
return candidate?.key ?? '/board';
|
return candidate?.key ?? '/board';
|
||||||
}, [location.pathname]);
|
}, [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 () => {
|
const loadBoard = async () => {
|
||||||
setLoadingBoard(true);
|
setLoadingBoard(true);
|
||||||
setGlobalError(null);
|
clearWorkflowError();
|
||||||
try {
|
try {
|
||||||
const payload = await loadDashboard(contextId);
|
const payload = await loadDashboard(contextId);
|
||||||
setBoardPayload(payload);
|
setBoardPayload(payload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setGlobalError(err instanceof Error ? err.message : 'Failed to load kitchen board.');
|
await handleWorkflowFailure(err, 'Failed to load kitchen board.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingBoard(false);
|
setLoadingBoard(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatePriority = async (request: SetKitchenOrderPriorityRequest) => {
|
const executeClaim = async (request: ClaimKitchenWorkItemRequest) => {
|
||||||
setSubmittingPriority(true);
|
setClaimingItem(true);
|
||||||
setGlobalError(null);
|
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 {
|
try {
|
||||||
const payload = await setKitchenOrderPriority(request);
|
const payload = await setKitchenOrderPriority(request);
|
||||||
setPriorityResponse(payload);
|
pushActionHistory({ kind: 'priority', response: payload });
|
||||||
// Keep recent responses bounded so the session view stays readable over long demos.
|
|
||||||
setPriorityHistory((previous) => [payload, ...previous].slice(0, 8));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setGlobalError(err instanceof Error ? err.message : 'Failed to update kitchen order priority.');
|
await handleWorkflowFailure(err, 'Failed to update kitchen order priority.');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmittingPriority(false);
|
setUpdatingPriority(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -117,6 +237,15 @@ function KitchenOpsShell() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{session.error && <Alert type="error" showIcon message={session.error} />}
|
{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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -151,15 +280,30 @@ function KitchenOpsShell() {
|
|||||||
<Layout.Content className="content">
|
<Layout.Content className="content">
|
||||||
<Typography.Title level={3}>Kitchen Operations</Typography.Title>
|
<Typography.Title level={3}>Kitchen Operations</Typography.Title>
|
||||||
<Typography.Paragraph type="secondary">
|
<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>
|
</Typography.Paragraph>
|
||||||
{session.error && <Alert className="stack-gap" type="warning" showIcon message={session.error} />}
|
{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>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/board"
|
path="/board"
|
||||||
element={
|
element={
|
||||||
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
<Card title="Kitchen Board">
|
<Card title="Kitchen Board">
|
||||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
@ -172,74 +316,164 @@ function KitchenOpsShell() {
|
|||||||
<Button type="primary" loading={loadingBoard} onClick={() => void loadBoard()}>
|
<Button type="primary" loading={loadingBoard} onClick={() => void loadBoard()}>
|
||||||
Load Kitchen Board
|
Load Kitchen Board
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button icon={<SyncOutlined />} onClick={() => void loadBoard()} disabled={loadingBoard}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
{boardPayload ? (
|
{boardPayload ? (
|
||||||
|
<>
|
||||||
<Descriptions bordered size="small" column={1}>
|
<Descriptions bordered size="small" column={1}>
|
||||||
<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>
|
</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>
|
</Space>
|
||||||
</Card>
|
</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
|
<Route
|
||||||
path="/priority"
|
path="/actions"
|
||||||
element={
|
element={
|
||||||
<Card title="Set Kitchen Priority">
|
|
||||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
<Form<SetKitchenOrderPriorityRequest>
|
<Card title="Claim Work Item">
|
||||||
|
<Form<ClaimKitchenWorkItemRequest>
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
initialValues={{
|
initialValues={{ contextId, workItemId: 'WORK-1001', claimedBy: 'chef-a' }}
|
||||||
contextId,
|
onFinish={(values) => void executeClaim(values)}
|
||||||
orderId: 'ORD-1001',
|
|
||||||
priority: 1,
|
|
||||||
updatedBy: 'kitchen-supervisor'
|
|
||||||
}}
|
|
||||||
onFinish={(values) => void updatePriority(values)}
|
|
||||||
>
|
>
|
||||||
<Form.Item name="contextId" label="Context Id" rules={[{ required: true }]}>
|
<Form.Item name="contextId" label="Context Id" rules={[{ required: true }]}>
|
||||||
<Input placeholder="Context Id" />
|
<Input placeholder="Context Id" />
|
||||||
</Form.Item>
|
</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 }]}>
|
<Form.Item name="orderId" label="Order Id" rules={[{ required: true }]}>
|
||||||
<Input placeholder="Order Id" />
|
<Input placeholder="Order Id" />
|
||||||
</Form.Item>
|
</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 }]}>
|
<Form.Item name="priority" label="Priority" rules={[{ required: true, type: 'number', min: 0 }]}>
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="updatedBy" label="Updated By" rules={[{ required: true }]}>
|
<Form.Item name="updatedBy" label="Updated By" rules={[{ required: true }]}>
|
||||||
<Input placeholder="Operator Id" />
|
<Input placeholder="Operator Id" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button type="primary" htmlType="submit" loading={submittingPriority}>
|
<Button type="primary" htmlType="submit" loading={updatingPriority}>
|
||||||
Set Kitchen Priority
|
Set Kitchen Priority
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
{priorityResponse && (
|
</Card>
|
||||||
<Descriptions bordered size="small" column={1}>
|
<Card title="Latest Operator Result">
|
||||||
<Descriptions.Item label="Context Id">{priorityResponse.contextId}</Descriptions.Item>
|
{lastAction ? <ActionDescriptions event={lastAction} /> : <Empty description="Run a kitchen action to inspect the latest response." />}
|
||||||
<Descriptions.Item label="Order Id">{priorityResponse.orderId}</Descriptions.Item>
|
</Card>
|
||||||
<Descriptions.Item label="Updated">{String(priorityResponse.updated)}</Descriptions.Item>
|
<Card title="Recent Operator Actions">
|
||||||
<Descriptions.Item label="Summary">{priorityResponse.summary}</Descriptions.Item>
|
<Table<ActionEvent>
|
||||||
</Descriptions>
|
|
||||||
)}
|
|
||||||
<Table<SetKitchenOrderPriorityResponse>
|
|
||||||
pagination={false}
|
pagination={false}
|
||||||
rowKey={(record) => `${record.contextId}-${record.orderId}`}
|
rowKey={(record) => getActionEventKey(record)}
|
||||||
dataSource={priorityHistory}
|
dataSource={actionHistory}
|
||||||
|
locale={{ emptyText: 'No recent kitchen actions yet.' }}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: 'Order Id', dataIndex: 'orderId' },
|
{ title: 'Action', render: (_, record) => <Tag>{record.kind}</Tag> },
|
||||||
{ title: 'Context Id', dataIndex: 'contextId' },
|
{ title: 'Reference', render: (_, record) => getActionReference(record) },
|
||||||
{
|
{ title: 'Summary', render: (_, record) => getActionSummary(record) }
|
||||||
title: 'Updated',
|
|
||||||
render: (_, record) => <Tag color={record.updated ? 'green' : 'red'}>{String(record.updated)}</Tag>
|
|
||||||
},
|
|
||||||
{ title: 'Summary', dataIndex: 'summary' }
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Space>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<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 {
|
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';
|
||||||
|
|||||||
@ -6,7 +6,13 @@ vi.mock('./client', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { getJson, postJson } from './client';
|
import { getJson, postJson } from './client';
|
||||||
import { loadDashboard, setKitchenOrderPriority } from './dashboardApi';
|
import {
|
||||||
|
claimKitchenWorkItem,
|
||||||
|
loadDashboard,
|
||||||
|
releaseKitchenWorkItem,
|
||||||
|
setKitchenOrderPriority,
|
||||||
|
transitionKitchenWorkItem
|
||||||
|
} from './dashboardApi';
|
||||||
|
|
||||||
describe('kitchen ops dashboard api', () => {
|
describe('kitchen ops dashboard api', () => {
|
||||||
it('builds encoded board endpoint path', async () => {
|
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');
|
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 () => {
|
it('posts priority update payload', async () => {
|
||||||
vi.mocked(postJson).mockResolvedValue({ updated: true });
|
vi.mocked(postJson).mockResolvedValue({ updated: true });
|
||||||
|
|
||||||
await setKitchenOrderPriority({
|
await setKitchenOrderPriority({
|
||||||
contextId: 'ctx',
|
contextId: 'ctx',
|
||||||
orderId: 'ORD-1',
|
workItemId: 'WORK-1',
|
||||||
priority: 2,
|
priority: 2,
|
||||||
updatedBy: 'chef'
|
updatedBy: 'chef'
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(postJson).toHaveBeenCalledWith('/api/kitchen/ops/board/priority', {
|
expect(postJson).toHaveBeenCalledWith('/api/kitchen/ops/board/priority', {
|
||||||
contextId: 'ctx',
|
contextId: 'ctx',
|
||||||
orderId: 'ORD-1',
|
workItemId: 'WORK-1',
|
||||||
priority: 2,
|
priority: 2,
|
||||||
updatedBy: 'chef'
|
updatedBy: 'chef'
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,21 +1,85 @@
|
|||||||
import { getJson, postJson } from './client';
|
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 = {
|
export type KitchenOpsBoardResponse = {
|
||||||
contextId: string;
|
contextId: string;
|
||||||
summary: 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 = {
|
export type SetKitchenOrderPriorityRequest = {
|
||||||
contextId: string;
|
contextId: string;
|
||||||
orderId: string;
|
workItemId: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
updatedBy: string;
|
updatedBy: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SetKitchenOrderPriorityResponse = {
|
export type SetKitchenOrderPriorityResponse = {
|
||||||
contextId: string;
|
contextId: string;
|
||||||
orderId: string;
|
workItemId: string;
|
||||||
updated: boolean;
|
updated: boolean;
|
||||||
|
priority: number;
|
||||||
summary: string;
|
summary: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -23,6 +87,24 @@ export async function loadDashboard(contextId: string): Promise<KitchenOpsBoardR
|
|||||||
return getJson<KitchenOpsBoardResponse>(`/api/kitchen/ops/board?contextId=${encodeURIComponent(contextId)}`);
|
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(
|
export async function setKitchenOrderPriority(
|
||||||
request: SetKitchenOrderPriorityRequest
|
request: SetKitchenOrderPriorityRequest
|
||||||
): Promise<SetKitchenOrderPriorityResponse> {
|
): Promise<SetKitchenOrderPriorityResponse> {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user