Compare commits
No commits in common. "development" and "feature/kitchen-ops-web-ant-protected-routes" have entirely different histories.
developmen
...
feature/ki
@ -5,7 +5,6 @@
|
|||||||
- 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
|
||||||
|
|
||||||
@ -17,17 +16,10 @@
|
|||||||
## 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 lanes with work-item detail and station coverage
|
- Kitchen board lookup
|
||||||
- Board event feed derived from the loaded lane state and shared restaurant lifecycle progression
|
- Kitchen priority updates
|
||||||
- Claim, release, transition, and priority operator actions
|
- Protected route shell for board, priority update, and session inspection
|
||||||
- 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.
|
|
||||||
|
|||||||
@ -21,14 +21,6 @@ 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
|
|
||||||
- `/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
|
## Build
|
||||||
|
|
||||||
|
|||||||
@ -15,11 +15,10 @@ 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 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/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
|
## 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.
|
|
||||||
|
|||||||
115
src/App.test.tsx
115
src/App.test.tsx
@ -1,6 +1,5 @@
|
|||||||
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(),
|
||||||
@ -10,30 +9,18 @@ vi.mock('./api/sessionApi', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./api/dashboardApi', () => ({
|
vi.mock('./api/dashboardApi', () => ({
|
||||||
claimKitchenWorkItem: vi.fn(),
|
|
||||||
loadDashboard: vi.fn(),
|
loadDashboard: vi.fn(),
|
||||||
releaseKitchenWorkItem: vi.fn(),
|
setKitchenOrderPriority: vi.fn()
|
||||||
setKitchenOrderPriority: vi.fn(),
|
|
||||||
transitionKitchenWorkItem: vi.fn()
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import { loadDashboard, setKitchenOrderPriority } from './api/dashboardApi';
|
||||||
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',
|
||||||
@ -55,36 +42,14 @@ describe('Kitchen Ops App', () => {
|
|||||||
expect(link.href).toContain('tenantId=demo-tenant');
|
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({
|
vi.mocked(getSessionMe).mockResolvedValue({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
subjectId: 'demo-user',
|
subjectId: 'demo-user',
|
||||||
tenantId: 'demo-tenant',
|
tenantId: 'demo-tenant',
|
||||||
provider: 2
|
provider: 0
|
||||||
});
|
|
||||||
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']
|
|
||||||
});
|
});
|
||||||
|
vi.mocked(loadDashboard).mockResolvedValue({ contextId: 'demo-context', summary: 'ready' });
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
@ -92,77 +57,29 @@ 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('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({
|
vi.mocked(getSessionMe).mockResolvedValue({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
subjectId: 'demo-user',
|
subjectId: 'demo-user',
|
||||||
tenantId: 'demo-tenant',
|
tenantId: 'demo-tenant',
|
||||||
provider: 2
|
provider: 0
|
||||||
});
|
});
|
||||||
vi.mocked(claimKitchenWorkItem).mockResolvedValue({
|
vi.mocked(setKitchenOrderPriority).mockResolvedValue({
|
||||||
contextId: 'demo-context',
|
contextId: 'demo-context',
|
||||||
workItemId: 'WORK-1',
|
orderId: 'ORD-2200',
|
||||||
claimed: true,
|
updated: true,
|
||||||
claimedBy: 'chef-a',
|
summary: 'updated'
|
||||||
message: 'claimed'
|
|
||||||
});
|
|
||||||
vi.mocked(transitionKitchenWorkItem).mockResolvedValue({
|
|
||||||
orderId: 'ORD-1',
|
|
||||||
ticketId: 'TICK-1',
|
|
||||||
previousState: 'Cooking',
|
|
||||||
currentState: 'Served',
|
|
||||||
transitioned: true,
|
|
||||||
error: null
|
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('Operator Actions')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Set Priority')).toBeInTheDocument());
|
||||||
fireEvent.click(screen.getByText('Operator Actions'));
|
fireEvent.click(screen.getByText('Set Priority'));
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Claim Work Item' }));
|
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));
|
await waitFor(() => expect(setKitchenOrderPriority).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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
469
src/App.tsx
469
src/App.tsx
@ -1,25 +1,15 @@
|
|||||||
import {
|
import { DeploymentUnitOutlined, OrderedListOutlined, ReloadOutlined, ToolOutlined } from '@ant-design/icons';
|
||||||
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,
|
||||||
@ -28,76 +18,25 @@ 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' | '/actions' | '/session';
|
type AppRoute = '/board' | '/priority' | '/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: '/actions', label: 'Operator Actions', icon: <ToolOutlined /> },
|
{ key: '/priority', label: 'Set Priority', 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={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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
@ -115,14 +54,11 @@ 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 [lastAction, setLastAction] = useState<ActionEvent | null>(null);
|
const [priorityResponse, setPriorityResponse] = useState<SetKitchenOrderPriorityResponse | null>(null);
|
||||||
const [actionHistory, setActionHistory] = useState<ActionEvent[]>([]);
|
const [priorityHistory, setPriorityHistory] = useState<SetKitchenOrderPriorityResponse[]>([]);
|
||||||
const [workflowState, setWorkflowState] = useState<WorkflowState>({ error: null, sessionExpired: false });
|
const [globalError, setGlobalError] = useState<string | null>(null);
|
||||||
const [loadingBoard, setLoadingBoard] = useState(false);
|
const [loadingBoard, setLoadingBoard] = useState(false);
|
||||||
const [claimingItem, setClaimingItem] = useState(false);
|
const [submittingPriority, setSubmittingPriority] = 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(() => {
|
||||||
@ -130,88 +66,32 @@ 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);
|
||||||
clearWorkflowError();
|
setGlobalError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await loadDashboard(contextId);
|
const payload = await loadDashboard(contextId);
|
||||||
setBoardPayload(payload);
|
setBoardPayload(payload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await handleWorkflowFailure(err, 'Failed to load kitchen board.');
|
setGlobalError(err instanceof Error ? err.message : 'Failed to load kitchen board.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingBoard(false);
|
setLoadingBoard(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const executeClaim = async (request: ClaimKitchenWorkItemRequest) => {
|
const updatePriority = async (request: SetKitchenOrderPriorityRequest) => {
|
||||||
setClaimingItem(true);
|
setSubmittingPriority(true);
|
||||||
clearWorkflowError();
|
setGlobalError(null);
|
||||||
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);
|
||||||
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) {
|
} catch (err) {
|
||||||
await handleWorkflowFailure(err, 'Failed to update kitchen order priority.');
|
setGlobalError(err instanceof Error ? err.message : 'Failed to update kitchen order priority.');
|
||||||
} finally {
|
} finally {
|
||||||
setUpdatingPriority(false);
|
setSubmittingPriority(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -237,15 +117,6 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -280,30 +151,15 @@ 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, claim or release actions, transitions, and priority updates.
|
Protected kitchen board workflows for board visibility 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} />}
|
||||||
{workflowState.error && (
|
{globalError && <Alert className="stack-gap" type="error" showIcon message={globalError} />}
|
||||||
<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>
|
||||||
@ -316,117 +172,33 @@ 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.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) => (
|
|
||||||
<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>
|
</Space>
|
||||||
</Card>
|
</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
|
<Route
|
||||||
path="/actions"
|
path="/priority"
|
||||||
element={
|
element={
|
||||||
|
<Card title="Set Kitchen Priority">
|
||||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
<Card title="Claim Work Item">
|
<Form<SetKitchenOrderPriorityRequest>
|
||||||
<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>
|
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
initialValues={{
|
initialValues={{
|
||||||
contextId,
|
contextId,
|
||||||
orderId: 'ORD-1001',
|
orderId: 'ORD-1001',
|
||||||
ticketId: 'TICK-1001',
|
priority: 1,
|
||||||
targetState: 'Ready',
|
updatedBy: 'kitchen-supervisor'
|
||||||
updatedBy: 'chef-a'
|
|
||||||
}}
|
}}
|
||||||
onFinish={(values) => void executeTransition(values)}
|
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" />
|
||||||
@ -434,67 +206,40 @@ function KitchenOpsShell() {
|
|||||||
<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={updatingPriority}>
|
<Button type="primary" htmlType="submit" loading={submittingPriority}>
|
||||||
Set Kitchen Priority
|
Set Kitchen Priority
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
{priorityResponse && (
|
||||||
<Card title="Latest Operator Result">
|
<Descriptions bordered size="small" column={1}>
|
||||||
{lastAction ? <ActionDescriptions event={lastAction} /> : <Empty description="Run a kitchen action to inspect the latest response." />}
|
<Descriptions.Item label="Context Id">{priorityResponse.contextId}</Descriptions.Item>
|
||||||
</Card>
|
<Descriptions.Item label="Order Id">{priorityResponse.orderId}</Descriptions.Item>
|
||||||
<Card title="Recent Operator Actions">
|
<Descriptions.Item label="Updated">{String(priorityResponse.updated)}</Descriptions.Item>
|
||||||
<Table<ActionEvent>
|
<Descriptions.Item label="Summary">{priorityResponse.summary}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
<Table<SetKitchenOrderPriorityResponse>
|
||||||
pagination={false}
|
pagination={false}
|
||||||
rowKey={(record) => getActionEventKey(record)}
|
rowKey={(record) => `${record.contextId}-${record.orderId}`}
|
||||||
dataSource={actionHistory}
|
dataSource={priorityHistory}
|
||||||
locale={{ emptyText: 'No recent kitchen actions yet.' }}
|
|
||||||
columns={[
|
columns={[
|
||||||
{ title: 'Action', render: (_, record) => <Tag>{record.kind}</Tag> },
|
{ title: 'Order Id', dataIndex: 'orderId' },
|
||||||
{ title: 'Reference', render: (_, record) => getActionReference(record) },
|
{ title: 'Context Id', dataIndex: 'contextId' },
|
||||||
{ title: 'Summary', render: (_, record) => getActionSummary(record) }
|
{
|
||||||
|
title: 'Updated',
|
||||||
|
render: (_, record) => <Tag color={record.updated ? 'green' : 'red'}>{String(record.updated)}</Tag>
|
||||||
|
},
|
||||||
|
{ title: 'Summary', dataIndex: 'summary' }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Card>
|
|
||||||
</Space>
|
</Space>
|
||||||
|
</Card>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<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 {
|
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,13 +6,7 @@ vi.mock('./client', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { getJson, postJson } from './client';
|
import { getJson, postJson } from './client';
|
||||||
import {
|
import { loadDashboard, setKitchenOrderPriority } from './dashboardApi';
|
||||||
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 () => {
|
||||||
@ -23,69 +17,19 @@ 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',
|
||||||
workItemId: 'WORK-1',
|
orderId: 'ORD-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',
|
||||||
workItemId: 'WORK-1',
|
orderId: 'ORD-1',
|
||||||
priority: 2,
|
priority: 2,
|
||||||
updatedBy: 'chef'
|
updatedBy: 'chef'
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,86 +1,21 @@
|
|||||||
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;
|
|
||||||
contextId?: 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;
|
||||||
workItemId: string;
|
orderId: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
updatedBy: string;
|
updatedBy: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SetKitchenOrderPriorityResponse = {
|
export type SetKitchenOrderPriorityResponse = {
|
||||||
contextId: string;
|
contextId: string;
|
||||||
workItemId: string;
|
orderId: string;
|
||||||
updated: boolean;
|
updated: boolean;
|
||||||
priority: number;
|
|
||||||
summary: string;
|
summary: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -88,24 +23,6 @@ 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