diff --git a/docs/architecture/frontend-boundary.md b/docs/architecture/frontend-boundary.md index 2a93fef..f4d64e7 100644 --- a/docs/architecture/frontend-boundary.md +++ b/docs/architecture/frontend-boundary.md @@ -5,6 +5,7 @@ - The UI does not access DAL or internal services directly. - 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. +- Session-expired responses are treated as an auth boundary concern and trigger revalidation before the UI prompts for login again. ## Runtime Base URLs @@ -16,10 +17,16 @@ ## Protected Workflow Endpoints - `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` ## UI Workflow Coverage -- Kitchen board lookup -- Kitchen priority updates -- Protected route shell for board, priority update, and session inspection +- Kitchen board lanes with work-item detail and station coverage +- Board event feed derived from the loaded lane state +- 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 diff --git a/docs/runbooks/local-development.md b/docs/runbooks/local-development.md index 6f2dcb6..14838af 100644 --- a/docs/runbooks/local-development.md +++ b/docs/runbooks/local-development.md @@ -21,6 +21,13 @@ npm run dev - Login is executed via central Thalos OIDC start endpoint. - Business calls are gated behind session checks. - 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 diff --git a/docs/runbooks/testing.md b/docs/runbooks/testing.md index 5552af5..5f4fe75 100644 --- a/docs/runbooks/testing.md +++ b/docs/runbooks/testing.md @@ -15,10 +15,11 @@ npm run test:ci ## Coverage Scope - `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/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 - Use containerized Node execution when host `npm` is unavailable. +- Prefer container-first validation before opening or updating runtime stack images. diff --git a/src/App.test.tsx b/src/App.test.tsx index d10f0a8..1e13ced 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ApiError } from './api/client'; vi.mock('./api/sessionApi', () => ({ getSessionMe: vi.fn(), @@ -9,18 +10,30 @@ vi.mock('./api/sessionApi', () => ({ })); vi.mock('./api/dashboardApi', () => ({ + claimKitchenWorkItem: 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 App from './App'; describe('Kitchen Ops App', () => { beforeEach(() => { + vi.mocked(claimKitchenWorkItem).mockReset(); vi.mocked(loadDashboard).mockReset(); + vi.mocked(releaseKitchenWorkItem).mockReset(); vi.mocked(setKitchenOrderPriority).mockReset(); + vi.mocked(transitionKitchenWorkItem).mockReset(); vi.mocked(getSessionMe).mockReset(); window.__APP_CONFIG__ = { API_BASE_URL: 'http://localhost:8080', @@ -42,14 +55,36 @@ describe('Kitchen Ops App', () => { 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({ isAuthenticated: true, subjectId: 'demo-user', 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(); @@ -57,29 +92,72 @@ describe('Kitchen Ops App', () => { fireEvent.click(screen.getByRole('button', { name: 'Load Kitchen Board' })); await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context')); + expect(await screen.findByText('Lane: Cooking')).toBeInTheDocument(); + expect(await screen.findByText('Cooking: WORK-1 at Grill is Cooking')).toBeInTheDocument(); }); - it('updates kitchen priority from action route', async () => { + it('runs operator actions from the actions route', async () => { vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: true, subjectId: 'demo-user', tenantId: 'demo-tenant', - provider: 0 + provider: 2 }); - vi.mocked(setKitchenOrderPriority).mockResolvedValue({ + vi.mocked(claimKitchenWorkItem).mockResolvedValue({ contextId: 'demo-context', - orderId: 'ORD-2200', - updated: true, - summary: 'updated' + workItemId: 'WORK-1', + claimed: true, + claimedBy: 'chef-a', + message: 'claimed' + }); + vi.mocked(transitionKitchenWorkItem).mockResolvedValue({ + orderId: 'ORD-1', + ticketId: 'TICK-1', + previousState: 'Cooking', + currentState: 'Ready', + transitioned: true, + error: null }); render(); - await waitFor(() => expect(screen.getByText('Set Priority')).toBeInTheDocument()); - fireEvent.click(screen.getByText('Set Priority')); - fireEvent.change(screen.getByPlaceholderText('Order Id'), { target: { value: 'ORD-2200' } }); - fireEvent.click(screen.getByRole('button', { name: 'Set Kitchen Priority' })); + await waitFor(() => expect(screen.getByText('Operator Actions')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Operator Actions')); + fireEvent.click(screen.getByRole('button', { name: 'Claim Work Item' })); - 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(); + + 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(); }); }); diff --git a/src/App.tsx b/src/App.tsx index eaf4a7b..8b7643f 100644 --- a/src/App.tsx +++ b/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 { Alert, Button, Card, Descriptions, + Empty, Form, Input, InputNumber, Layout, + List, Menu, Result, + Select, Space, Spin, Table, @@ -18,25 +28,76 @@ import { } from 'antd'; import { type ReactNode, useMemo, useState } from 'react'; import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; +import { ApiError } from './api/client'; import { + claimKitchenWorkItem, loadDashboard, + releaseKitchenWorkItem, setKitchenOrderPriority, + transitionKitchenWorkItem, + type ClaimKitchenWorkItemRequest, + type ClaimKitchenWorkItemResponse, + type KitchenBoardItem, type KitchenOpsBoardResponse, + type ReleaseKitchenWorkItemRequest, + type ReleaseKitchenWorkItemResponse, type SetKitchenOrderPriorityRequest, - type SetKitchenOrderPriorityResponse + type SetKitchenOrderPriorityResponse, + type TransitionKitchenWorkItemRequest, + type TransitionKitchenWorkItemResponse } from './api/dashboardApi'; import type { IdentityProvider } from './api/sessionApi'; import { buildGoogleOidcStartUrl } from './auth/oidcLogin'; 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 }> = [ { key: '/board', label: 'Kitchen Board', icon: }, - { key: '/priority', label: 'Set Priority', icon: }, + { key: '/actions', label: 'Operator Actions', icon: }, { key: '/session', label: 'Session', icon: } ]; +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) => {value} + }, + { 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() { return ( @@ -54,11 +115,14 @@ function KitchenOpsShell() { const [contextId, setContextId] = useState('demo-context'); const [boardPayload, setBoardPayload] = useState(null); - const [priorityResponse, setPriorityResponse] = useState(null); - const [priorityHistory, setPriorityHistory] = useState([]); - const [globalError, setGlobalError] = useState(null); + const [lastAction, setLastAction] = useState(null); + const [actionHistory, setActionHistory] = useState([]); + const [workflowState, setWorkflowState] = useState({ error: null, sessionExpired: 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 selectedKey = useMemo(() => { @@ -66,32 +130,88 @@ function KitchenOpsShell() { return candidate?.key ?? '/board'; }, [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 () => { setLoadingBoard(true); - setGlobalError(null); + clearWorkflowError(); try { const payload = await loadDashboard(contextId); setBoardPayload(payload); } catch (err) { - setGlobalError(err instanceof Error ? err.message : 'Failed to load kitchen board.'); + await handleWorkflowFailure(err, 'Failed to load kitchen board.'); } finally { setLoadingBoard(false); } }; - const updatePriority = async (request: SetKitchenOrderPriorityRequest) => { - setSubmittingPriority(true); - setGlobalError(null); + const executeClaim = async (request: ClaimKitchenWorkItemRequest) => { + setClaimingItem(true); + 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 { const payload = await setKitchenOrderPriority(request); - setPriorityResponse(payload); - // Keep recent responses bounded so the session view stays readable over long demos. - setPriorityHistory((previous) => [payload, ...previous].slice(0, 8)); + pushActionHistory({ kind: 'priority', response: payload }); } catch (err) { - setGlobalError(err instanceof Error ? err.message : 'Failed to update kitchen order priority.'); + await handleWorkflowFailure(err, 'Failed to update kitchen order priority.'); } finally { - setSubmittingPriority(false); + setUpdatingPriority(false); } }; @@ -117,6 +237,15 @@ function KitchenOpsShell() { } /> {session.error && } + {workflowState.sessionExpired && ( + + )} ); } @@ -151,95 +280,200 @@ function KitchenOpsShell() { Kitchen Operations - 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. {session.error && } - {globalError && } + {workflowState.error && ( + + Reauthenticate + + ) : undefined + } + /> + )} - - - setContextId(event.target.value)} - placeholder="Context Id" - style={{ width: 280 }} - /> - + + + + + setContextId(event.target.value)} + placeholder="Context Id" + style={{ width: 280 }} + /> + + + + {boardPayload ? ( + <> + + {boardPayload.contextId} + {boardPayload.summary} + {boardPayload.availableStations.join(', ')} + + {boardPayload.lanes.map((lane) => ( + + + pagination={false} + rowKey={(record) => record.workItemId} + dataSource={lane.items} + columns={boardColumns} + locale={{ emptyText: 'No work items in this lane.' }} + /> + + ))} + + ) : ( + + )} + + }> {boardPayload ? ( - - {boardPayload.contextId} - {boardPayload.summary} - + + lane.items.map((item) => `${lane.lane}: ${item.workItemId} at ${item.station} is ${item.state}`) + )} + renderItem={(item) => {item}} + /> ) : ( - No kitchen board payload loaded. + )} - - + + } /> - - + + + layout="vertical" - initialValues={{ - contextId, - orderId: 'ORD-1001', - priority: 1, - updatedBy: 'kitchen-supervisor' - }} - onFinish={(values) => void updatePriority(values)} + initialValues={{ contextId, workItemId: 'WORK-1001', claimedBy: 'chef-a' }} + onFinish={(values) => void executeClaim(values)} > + + + + + + + + + + + + layout="vertical" + initialValues={{ contextId, workItemId: 'WORK-1001', releasedBy: 'chef-a' }} + onFinish={(values) => void executeRelease(values)} + > + + + + + + + + + + + + + + + layout="vertical" + initialValues={{ orderId: 'ORD-1001', ticketId: 'TICK-1001', targetState: 'Ready', updatedBy: 'chef-a' }} + onFinish={(values) => void executeTransition(values)} + > + + + + + + + + + + + + layout="vertical" + initialValues={{ contextId, workItemId: 'WORK-1001', priority: 1, updatedBy: 'kitchen-supervisor' }} + onFinish={(values) => void executePriorityUpdate(values)} + > + + + + + + - - {priorityResponse && ( - - {priorityResponse.contextId} - {priorityResponse.orderId} - {String(priorityResponse.updated)} - {priorityResponse.summary} - - )} - + + + {lastAction ? : } + + + pagination={false} - rowKey={(record) => `${record.contextId}-${record.orderId}`} - dataSource={priorityHistory} + rowKey={(record) => getActionEventKey(record)} + dataSource={actionHistory} + locale={{ emptyText: 'No recent kitchen actions yet.' }} columns={[ - { title: 'Order Id', dataIndex: 'orderId' }, - { title: 'Context Id', dataIndex: 'contextId' }, - { - title: 'Updated', - render: (_, record) => {String(record.updated)} - }, - { title: 'Summary', dataIndex: 'summary' } + { title: 'Action', render: (_, record) => {record.kind} }, + { title: 'Reference', render: (_, record) => getActionReference(record) }, + { title: 'Summary', render: (_, record) => getActionSummary(record) } ]} /> - - + + } /> + {event.response.contextId} + {event.response.workItemId} + {String(event.response.claimed)} + {event.response.claimedBy} + {event.response.message} + + ); + } + + if (event.kind === 'release') { + return ( + + {event.response.contextId} + {event.response.workItemId} + {String(event.response.released)} + {event.response.releasedBy} + {event.response.message} + + ); + } + + if (event.kind === 'transition') { + return ( + + {event.response.orderId} + {event.response.ticketId} + {event.response.previousState} + {event.response.currentState} + {String(event.response.transitioned)} + {event.response.error ?? 'None'} + + ); + } + + return ( + + {event.response.contextId} + {event.response.workItemId} + {String(event.response.updated)} + {event.response.priority} + {event.response.summary} + + ); +} + +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 { if (provider === 0 || provider === '0' || provider === 'InternalJwt') { return 'Internal JWT'; diff --git a/src/api/dashboardApi.test.ts b/src/api/dashboardApi.test.ts index 4feb88a..49d57dc 100644 --- a/src/api/dashboardApi.test.ts +++ b/src/api/dashboardApi.test.ts @@ -6,7 +6,13 @@ vi.mock('./client', () => ({ })); import { getJson, postJson } from './client'; -import { loadDashboard, setKitchenOrderPriority } from './dashboardApi'; +import { + claimKitchenWorkItem, + loadDashboard, + releaseKitchenWorkItem, + setKitchenOrderPriority, + transitionKitchenWorkItem +} from './dashboardApi'; describe('kitchen ops dashboard api', () => { 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'); }); + 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 () => { vi.mocked(postJson).mockResolvedValue({ updated: true }); await setKitchenOrderPriority({ contextId: 'ctx', - orderId: 'ORD-1', + workItemId: 'WORK-1', priority: 2, updatedBy: 'chef' }); expect(postJson).toHaveBeenCalledWith('/api/kitchen/ops/board/priority', { contextId: 'ctx', - orderId: 'ORD-1', + workItemId: 'WORK-1', priority: 2, updatedBy: 'chef' }); diff --git a/src/api/dashboardApi.ts b/src/api/dashboardApi.ts index 9a835be..c9cc923 100644 --- a/src/api/dashboardApi.ts +++ b/src/api/dashboardApi.ts @@ -1,21 +1,85 @@ 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 = { contextId: 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 = { contextId: string; - orderId: string; + workItemId: string; priority: number; updatedBy: string; }; export type SetKitchenOrderPriorityResponse = { contextId: string; - orderId: string; + workItemId: string; updated: boolean; + priority: number; summary: string; }; @@ -23,6 +87,24 @@ export async function loadDashboard(contextId: string): Promise(`/api/kitchen/ops/board?contextId=${encodeURIComponent(contextId)}`); } +export async function claimKitchenWorkItem( + request: ClaimKitchenWorkItemRequest +): Promise { + return postJson('/api/kitchen/ops/work-items/claim', request); +} + +export async function releaseKitchenWorkItem( + request: ReleaseKitchenWorkItemRequest +): Promise { + return postJson('/api/kitchen/ops/work-items/release', request); +} + +export async function transitionKitchenWorkItem( + request: TransitionKitchenWorkItemRequest +): Promise { + return postJson('/api/kitchen/ops/work-items/transition', request); +} + export async function setKitchenOrderPriority( request: SetKitchenOrderPriorityRequest ): Promise {