Compare commits
No commits in common. "development" and "feature/restaurant-admin-web-ant-protected-routes" have entirely different histories.
developmen
...
feature/re
@ -16,12 +16,10 @@
|
|||||||
## Protected Workflow Endpoints
|
## Protected Workflow Endpoints
|
||||||
|
|
||||||
- `GET /api/restaurant/admin/config?contextId=...`
|
- `GET /api/restaurant/admin/config?contextId=...`
|
||||||
- `GET /api/restaurant/admin/changes?contextId=...`
|
|
||||||
- `POST /api/restaurant/admin/service-window`
|
- `POST /api/restaurant/admin/service-window`
|
||||||
|
|
||||||
## UI Workflow Coverage
|
## UI Workflow Coverage
|
||||||
|
|
||||||
- Restaurant config snapshot lookup with version, feature flags, and service windows
|
- Restaurant config lookup
|
||||||
- Recent configuration change inspection
|
- Service-window management
|
||||||
- Service-window management with latest applied snapshot and local action history
|
|
||||||
- Protected route shell for config, service-window, and session inspection
|
- Protected route shell for config, service-window, and session inspection
|
||||||
|
|||||||
@ -21,7 +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`.
|
||||||
- Unauthorized control-plane calls surface session-expired guidance and direct the operator back to the central auth host.
|
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
|||||||
@ -15,9 +15,9 @@ 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`: config, recent-change, and service-window endpoint mapping.
|
- `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, protected admin snapshot flow, service-window workflow, and reauthentication guidance.
|
- `src/App.test.tsx`: central login screen, protected config flow, and service-window workflow.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@ -10,19 +10,16 @@ vi.mock('./api/sessionApi', () => ({
|
|||||||
|
|
||||||
vi.mock('./api/dashboardApi', () => ({
|
vi.mock('./api/dashboardApi', () => ({
|
||||||
loadDashboard: vi.fn(),
|
loadDashboard: vi.fn(),
|
||||||
loadRecentChanges: vi.fn(),
|
|
||||||
setServiceWindow: vi.fn()
|
setServiceWindow: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { loadDashboard, loadRecentChanges, setServiceWindow } from './api/dashboardApi';
|
import { loadDashboard, setServiceWindow } from './api/dashboardApi';
|
||||||
import { ApiError } from './api/client';
|
|
||||||
import { getSessionMe } from './api/sessionApi';
|
import { getSessionMe } from './api/sessionApi';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
describe('Restaurant Admin App', () => {
|
describe('Restaurant Admin App', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(loadDashboard).mockReset();
|
vi.mocked(loadDashboard).mockReset();
|
||||||
vi.mocked(loadRecentChanges).mockReset();
|
|
||||||
vi.mocked(setServiceWindow).mockReset();
|
vi.mocked(setServiceWindow).mockReset();
|
||||||
vi.mocked(getSessionMe).mockReset();
|
vi.mocked(getSessionMe).mockReset();
|
||||||
window.__APP_CONFIG__ = {
|
window.__APP_CONFIG__ = {
|
||||||
@ -45,79 +42,34 @@ describe('Restaurant Admin App', () => {
|
|||||||
expect(link.href).toContain('tenantId=demo-tenant');
|
expect(link.href).toContain('tenantId=demo-tenant');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads admin config and recent changes for authenticated users', async () => {
|
it('loads admin config 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: 'configured',
|
|
||||||
version: 'v2',
|
|
||||||
featureFlags: [{ key: 'pos.closeout.preview', enabled: true }],
|
|
||||||
serviceWindows: [{ day: 1, openAt: '08:00:00', closeAt: '22:00:00', isClosed: false }],
|
|
||||||
recentChanges: []
|
|
||||||
});
|
|
||||||
vi.mocked(loadRecentChanges).mockResolvedValue({
|
|
||||||
contextId: 'demo-context',
|
|
||||||
summary: 'changes',
|
|
||||||
version: 'v2',
|
|
||||||
recentChanges: [
|
|
||||||
{
|
|
||||||
changeId: 'CFG-100',
|
|
||||||
category: 'feature-flag',
|
|
||||||
description: 'Enabled POS closeout preview mode.',
|
|
||||||
updatedBy: 'ops-lead',
|
|
||||||
updatedAtUtc: '2026-03-31T12:00:00Z'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
});
|
||||||
|
vi.mocked(loadDashboard).mockResolvedValue({ contextId: 'demo-context', summary: 'configured' });
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByRole('button', { name: /Load Config/ })).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Load Config' })).toBeInTheDocument());
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Load Config/ }));
|
fireEvent.click(screen.getByRole('button', { name: 'Load Config' }));
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Load Recent Changes/ }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context'));
|
await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context'));
|
||||||
await waitFor(() => expect(loadRecentChanges).toHaveBeenCalledWith('demo-context'));
|
|
||||||
expect(await screen.findByText('pos.closeout.preview')).toBeInTheDocument();
|
|
||||||
expect(await screen.findByText('CFG-100')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies service window and shows the latest applied snapshot', async () => {
|
it('applies service window 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(loadDashboard).mockResolvedValue({
|
|
||||||
contextId: 'demo-context',
|
|
||||||
summary: 'configured',
|
|
||||||
version: 'v2',
|
|
||||||
featureFlags: [],
|
|
||||||
serviceWindows: [],
|
|
||||||
recentChanges: []
|
|
||||||
});
|
|
||||||
vi.mocked(loadRecentChanges).mockResolvedValue({
|
|
||||||
contextId: 'demo-context',
|
|
||||||
summary: 'changes',
|
|
||||||
version: 'v2',
|
|
||||||
recentChanges: []
|
|
||||||
});
|
});
|
||||||
vi.mocked(setServiceWindow).mockResolvedValue({
|
vi.mocked(setServiceWindow).mockResolvedValue({
|
||||||
contextId: 'demo-context',
|
contextId: 'demo-context',
|
||||||
applied: true,
|
applied: true,
|
||||||
message: 'Service window updated by admin-operator.',
|
message: 'applied'
|
||||||
serviceWindow: {
|
|
||||||
day: 1,
|
|
||||||
openAt: '08:00:00',
|
|
||||||
closeAt: '22:00:00',
|
|
||||||
isClosed: false
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
@ -127,27 +79,5 @@ describe('Restaurant Admin App', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'Apply Service Window' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Apply Service Window' }));
|
||||||
|
|
||||||
await waitFor(() => expect(setServiceWindow).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(setServiceWindow).toHaveBeenCalledTimes(1));
|
||||||
expect(await screen.findAllByText('Service window updated by admin-operator.')).toHaveLength(2);
|
|
||||||
expect((await screen.findAllByText('Monday')).length).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows reauthentication guidance when the session expires', 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, 'Unauthorized request.', 'unauthorized', 'corr-401'));
|
|
||||||
|
|
||||||
render(<App />);
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByRole('button', { name: /Load Config/ })).toBeInTheDocument());
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Load Config/ }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('Authentication Required')).toBeInTheDocument());
|
|
||||||
expect(screen.getByText('Session expired')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
197
src/App.tsx
197
src/App.tsx
@ -1,16 +1,9 @@
|
|||||||
import {
|
import { ControlOutlined, DeploymentUnitOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
ControlOutlined,
|
|
||||||
DeploymentUnitOutlined,
|
|
||||||
HistoryOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
SettingOutlined
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Empty,
|
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
Layout,
|
Layout,
|
||||||
@ -24,19 +17,13 @@ import {
|
|||||||
TimePicker,
|
TimePicker,
|
||||||
Typography
|
Typography
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import dayjs, { type Dayjs } from 'dayjs';
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
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 {
|
||||||
loadDashboard,
|
loadDashboard,
|
||||||
loadRecentChanges,
|
|
||||||
setServiceWindow,
|
setServiceWindow,
|
||||||
type ConfigChange,
|
|
||||||
type FeatureFlagState,
|
|
||||||
type RecentAdminChangesResponse,
|
|
||||||
type RestaurantAdminConfigResponse,
|
type RestaurantAdminConfigResponse,
|
||||||
type ServiceWindow,
|
|
||||||
type SetServiceWindowRequest,
|
type SetServiceWindowRequest,
|
||||||
type SetServiceWindowResponse
|
type SetServiceWindowResponse
|
||||||
} from './api/dashboardApi';
|
} from './api/dashboardApi';
|
||||||
@ -46,11 +33,6 @@ import { SessionProvider, useSessionContext } from './auth/sessionContext';
|
|||||||
|
|
||||||
type AppRoute = '/config' | '/window' | '/session';
|
type AppRoute = '/config' | '/window' | '/session';
|
||||||
|
|
||||||
type WorkflowState = {
|
|
||||||
error: string | null;
|
|
||||||
sessionExpired: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ServiceWindowFormValues = {
|
type ServiceWindowFormValues = {
|
||||||
contextId: string;
|
contextId: string;
|
||||||
day: number;
|
day: number;
|
||||||
@ -92,12 +74,10 @@ function RestaurantAdminShell() {
|
|||||||
|
|
||||||
const [contextId, setContextId] = useState('demo-context');
|
const [contextId, setContextId] = useState('demo-context');
|
||||||
const [configPayload, setConfigPayload] = useState<RestaurantAdminConfigResponse | null>(null);
|
const [configPayload, setConfigPayload] = useState<RestaurantAdminConfigResponse | null>(null);
|
||||||
const [changesPayload, setChangesPayload] = useState<RecentAdminChangesResponse | null>(null);
|
|
||||||
const [windowResponse, setWindowResponse] = useState<SetServiceWindowResponse | null>(null);
|
const [windowResponse, setWindowResponse] = useState<SetServiceWindowResponse | null>(null);
|
||||||
const [windowHistory, setWindowHistory] = useState<SetServiceWindowResponse[]>([]);
|
const [windowHistory, setWindowHistory] = useState<SetServiceWindowResponse[]>([]);
|
||||||
const [workflowState, setWorkflowState] = useState<WorkflowState>({ error: null, sessionExpired: false });
|
const [globalError, setGlobalError] = useState<string | null>(null);
|
||||||
const [loadingConfig, setLoadingConfig] = useState(false);
|
const [loadingConfig, setLoadingConfig] = useState(false);
|
||||||
const [loadingChanges, setLoadingChanges] = useState(false);
|
|
||||||
const [submittingWindow, setSubmittingWindow] = useState(false);
|
const [submittingWindow, setSubmittingWindow] = useState(false);
|
||||||
|
|
||||||
const loginUrl = useMemo(() => buildGoogleOidcStartUrl(window.location.href), []);
|
const loginUrl = useMemo(() => buildGoogleOidcStartUrl(window.location.href), []);
|
||||||
@ -106,55 +86,22 @@ function RestaurantAdminShell() {
|
|||||||
return candidate?.key ?? '/config';
|
return candidate?.key ?? '/config';
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
const recentChanges = changesPayload?.recentChanges ?? configPayload?.recentChanges ?? [];
|
|
||||||
|
|
||||||
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 restaurant administration work.',
|
|
||||||
sessionExpired: true
|
|
||||||
});
|
|
||||||
await session.revalidate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setWorkflowState({
|
|
||||||
error: err instanceof Error ? err.message : fallbackMessage,
|
|
||||||
sessionExpired: false
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
setLoadingConfig(true);
|
setLoadingConfig(true);
|
||||||
clearWorkflowError();
|
setGlobalError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await loadDashboard(contextId);
|
const payload = await loadDashboard(contextId);
|
||||||
setConfigPayload(payload);
|
setConfigPayload(payload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await handleWorkflowFailure(err, 'Failed to load restaurant admin configuration.');
|
setGlobalError(err instanceof Error ? err.message : 'Failed to load restaurant admin configuration.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingConfig(false);
|
setLoadingConfig(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshChanges = async () => {
|
|
||||||
setLoadingChanges(true);
|
|
||||||
clearWorkflowError();
|
|
||||||
try {
|
|
||||||
const payload = await loadRecentChanges(contextId);
|
|
||||||
setChangesPayload(payload);
|
|
||||||
} catch (err) {
|
|
||||||
await handleWorkflowFailure(err, 'Failed to load recent configuration changes.');
|
|
||||||
} finally {
|
|
||||||
setLoadingChanges(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyServiceWindow = async (values: ServiceWindowFormValues) => {
|
const applyServiceWindow = async (values: ServiceWindowFormValues) => {
|
||||||
setSubmittingWindow(true);
|
setSubmittingWindow(true);
|
||||||
clearWorkflowError();
|
setGlobalError(null);
|
||||||
|
|
||||||
const request: SetServiceWindowRequest = {
|
const request: SetServiceWindowRequest = {
|
||||||
contextId: values.contextId,
|
contextId: values.contextId,
|
||||||
@ -167,11 +114,10 @@ function RestaurantAdminShell() {
|
|||||||
try {
|
try {
|
||||||
const payload = await setServiceWindow(request);
|
const payload = await setServiceWindow(request);
|
||||||
setWindowResponse(payload);
|
setWindowResponse(payload);
|
||||||
|
// Keep recent responses bounded so the session view stays readable over long demos.
|
||||||
setWindowHistory((previous) => [payload, ...previous].slice(0, 8));
|
setWindowHistory((previous) => [payload, ...previous].slice(0, 8));
|
||||||
await loadConfig();
|
|
||||||
await refreshChanges();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await handleWorkflowFailure(err, 'Failed to set service window.');
|
setGlobalError(err instanceof Error ? err.message : 'Failed to set service window.');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmittingWindow(false);
|
setSubmittingWindow(false);
|
||||||
}
|
}
|
||||||
@ -198,14 +144,6 @@ function RestaurantAdminShell() {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{workflowState.sessionExpired && (
|
|
||||||
<Alert
|
|
||||||
type="warning"
|
|
||||||
showIcon
|
|
||||||
message="Session expired"
|
|
||||||
description="Sign in again through the central auth host before retrying control-plane changes."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{session.error && <Alert type="error" showIcon message={session.error} />}
|
{session.error && <Alert type="error" showIcon message={session.error} />}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
@ -241,36 +179,16 @@ function RestaurantAdminShell() {
|
|||||||
<Layout.Content className="content">
|
<Layout.Content className="content">
|
||||||
<Typography.Title level={3}>Restaurant Administration</Typography.Title>
|
<Typography.Title level={3}>Restaurant Administration</Typography.Title>
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
Protected control-plane workflows for configuration snapshots, change history, and service-window management.
|
Protected control-plane workflows for restaurant configuration and service windows.
|
||||||
</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 && <Alert className="stack-gap" type="error" showIcon message={workflowState.error} />}
|
{globalError && <Alert className="stack-gap" type="error" showIcon message={globalError} />}
|
||||||
{workflowState.sessionExpired && (
|
|
||||||
<Alert
|
|
||||||
className="stack-gap"
|
|
||||||
type="warning"
|
|
||||||
showIcon
|
|
||||||
message="Session expired"
|
|
||||||
description="Refresh your session or sign in again before retrying admin operations."
|
|
||||||
action={
|
|
||||||
<Space>
|
|
||||||
<Button size="small" onClick={() => void session.refresh()}>
|
|
||||||
Refresh Session
|
|
||||||
</Button>
|
|
||||||
<Button size="small" type="primary" href={loginUrl}>
|
|
||||||
Reauthenticate
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/config"
|
path="/config"
|
||||||
element={
|
element={
|
||||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
<Card title="Configuration">
|
||||||
<Card title="Configuration Snapshot">
|
|
||||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Input
|
<Input
|
||||||
@ -282,83 +200,24 @@ function RestaurantAdminShell() {
|
|||||||
<Button type="primary" loading={loadingConfig} onClick={() => void loadConfig()}>
|
<Button type="primary" loading={loadingConfig} onClick={() => void loadConfig()}>
|
||||||
Load Config
|
Load Config
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<HistoryOutlined />} loading={loadingChanges} onClick={() => void refreshChanges()}>
|
|
||||||
Load Recent Changes
|
|
||||||
</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
{configPayload ? (
|
{configPayload ? (
|
||||||
<Descriptions bordered size="small" column={1}>
|
<Descriptions bordered size="small" column={1}>
|
||||||
<Descriptions.Item label="Context Id">{configPayload.contextId}</Descriptions.Item>
|
<Descriptions.Item label="Context Id">{configPayload.contextId}</Descriptions.Item>
|
||||||
<Descriptions.Item label="Version">{configPayload.version}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Summary">{configPayload.summary}</Descriptions.Item>
|
<Descriptions.Item label="Summary">{configPayload.summary}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
) : (
|
) : (
|
||||||
<Empty description="Load a configuration snapshot to inspect flags and service windows." />
|
<Typography.Text type="secondary">No admin configuration loaded.</Typography.Text>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Feature Flags">
|
|
||||||
<Table<FeatureFlagState>
|
|
||||||
pagination={false}
|
|
||||||
rowKey={(record) => record.key}
|
|
||||||
dataSource={configPayload?.featureFlags ?? []}
|
|
||||||
locale={{ emptyText: 'No feature flags loaded.' }}
|
|
||||||
columns={[
|
|
||||||
{ title: 'Key', dataIndex: 'key' },
|
|
||||||
{
|
|
||||||
title: 'Enabled',
|
|
||||||
render: (_, record) => (
|
|
||||||
<Tag color={record.enabled ? 'green' : 'default'}>{record.enabled ? 'Enabled' : 'Disabled'}</Tag>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Service Windows">
|
|
||||||
<Table<ServiceWindow>
|
|
||||||
pagination={false}
|
|
||||||
rowKey={(record) => `${record.day}-${record.openAt}-${record.closeAt}`}
|
|
||||||
dataSource={configPayload?.serviceWindows ?? []}
|
|
||||||
locale={{ emptyText: 'No service windows loaded.' }}
|
|
||||||
columns={[
|
|
||||||
{ title: 'Day', render: (_, record) => dayLabel(record.day) },
|
|
||||||
{ title: 'Open At', dataIndex: 'openAt' },
|
|
||||||
{ title: 'Close At', dataIndex: 'closeAt' },
|
|
||||||
{
|
|
||||||
title: 'Status',
|
|
||||||
render: (_, record) => (
|
|
||||||
<Tag color={record.isClosed ? 'red' : 'green'}>{record.isClosed ? 'Closed' : 'Open'}</Tag>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Recent Changes">
|
|
||||||
<Table<ConfigChange>
|
|
||||||
pagination={false}
|
|
||||||
rowKey={(record) => record.changeId}
|
|
||||||
dataSource={recentChanges}
|
|
||||||
locale={{ emptyText: 'No recent changes loaded.' }}
|
|
||||||
columns={[
|
|
||||||
{ title: 'Change Id', dataIndex: 'changeId' },
|
|
||||||
{ title: 'Category', dataIndex: 'category' },
|
|
||||||
{ title: 'Description', dataIndex: 'description' },
|
|
||||||
{ title: 'Updated By', dataIndex: 'updatedBy' },
|
|
||||||
{ title: 'Updated At', render: (_, record) => formatUtc(record.updatedAtUtc) }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Space>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/window"
|
path="/window"
|
||||||
element={
|
element={
|
||||||
|
<Card title="Service Window">
|
||||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
<Card title="Service Window Action">
|
|
||||||
<Form<ServiceWindowFormValues>
|
<Form<ServiceWindowFormValues>
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
initialValues={{
|
initialValues={{
|
||||||
@ -389,35 +248,19 @@ function RestaurantAdminShell() {
|
|||||||
Apply Service Window
|
Apply Service Window
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
{windowResponse && (
|
||||||
|
|
||||||
<Card title="Latest Result">
|
|
||||||
{windowResponse ? (
|
|
||||||
<Descriptions bordered size="small" column={1}>
|
<Descriptions bordered size="small" column={1}>
|
||||||
<Descriptions.Item label="Context Id">{windowResponse.contextId}</Descriptions.Item>
|
<Descriptions.Item label="Context Id">{windowResponse.contextId}</Descriptions.Item>
|
||||||
<Descriptions.Item label="Applied">{String(windowResponse.applied)}</Descriptions.Item>
|
<Descriptions.Item label="Applied">{String(windowResponse.applied)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="Message">{windowResponse.message}</Descriptions.Item>
|
<Descriptions.Item label="Message">{windowResponse.message}</Descriptions.Item>
|
||||||
<Descriptions.Item label="Day">{dayLabel(windowResponse.serviceWindow.day)}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Open At">{windowResponse.serviceWindow.openAt}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Close At">{windowResponse.serviceWindow.closeAt}</Descriptions.Item>
|
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
) : (
|
|
||||||
<Empty description="Submit a service window change to inspect the latest applied snapshot." />
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Recent Service Window Actions">
|
|
||||||
<Table<SetServiceWindowResponse>
|
<Table<SetServiceWindowResponse>
|
||||||
pagination={false}
|
pagination={false}
|
||||||
rowKey={(record) =>
|
rowKey={(record) => `${record.contextId}-${record.message}-${record.applied ? '1' : '0'}`}
|
||||||
`${record.contextId}-${record.message}-${record.serviceWindow.day}-${record.serviceWindow.openAt}`
|
|
||||||
}
|
|
||||||
dataSource={windowHistory}
|
dataSource={windowHistory}
|
||||||
locale={{ emptyText: 'No service window actions submitted yet.' }}
|
|
||||||
columns={[
|
columns={[
|
||||||
{ title: 'Context Id', dataIndex: 'contextId' },
|
{ title: 'Context Id', dataIndex: 'contextId' },
|
||||||
{ title: 'Day', render: (_, record) => dayLabel(record.serviceWindow.day) },
|
|
||||||
{ title: 'Window', render: (_, record) => `${record.serviceWindow.openAt} - ${record.serviceWindow.closeAt}` },
|
|
||||||
{
|
{
|
||||||
title: 'Applied',
|
title: 'Applied',
|
||||||
render: (_, record) => <Tag color={record.applied ? 'green' : 'red'}>{String(record.applied)}</Tag>
|
render: (_, record) => <Tag color={record.applied ? 'green' : 'red'}>{String(record.applied)}</Tag>
|
||||||
@ -425,8 +268,8 @@ function RestaurantAdminShell() {
|
|||||||
{ title: 'Message', dataIndex: 'message' }
|
{ title: 'Message', dataIndex: 'message' }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Card>
|
|
||||||
</Space>
|
</Space>
|
||||||
|
</Card>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
@ -446,14 +289,6 @@ function RestaurantAdminShell() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dayLabel(day: number): string {
|
|
||||||
return dayOptions.find((option) => option.value === day)?.label ?? String(day);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUtc(value: string): string {
|
|
||||||
return new Date(value).toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
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,7 @@ vi.mock('./client', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { getJson, postJson } from './client';
|
import { getJson, postJson } from './client';
|
||||||
import { loadDashboard, loadRecentChanges, setServiceWindow } from './dashboardApi';
|
import { loadDashboard, setServiceWindow } from './dashboardApi';
|
||||||
|
|
||||||
describe('restaurant admin dashboard api', () => {
|
describe('restaurant admin dashboard api', () => {
|
||||||
it('builds encoded config endpoint path', async () => {
|
it('builds encoded config endpoint path', async () => {
|
||||||
@ -17,14 +17,6 @@ describe('restaurant admin dashboard api', () => {
|
|||||||
expect(getJson).toHaveBeenCalledWith('/api/restaurant/admin/config?contextId=ctx%20admin%2F1');
|
expect(getJson).toHaveBeenCalledWith('/api/restaurant/admin/config?contextId=ctx%20admin%2F1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds encoded recent changes endpoint path', async () => {
|
|
||||||
vi.mocked(getJson).mockResolvedValue({ ok: true });
|
|
||||||
|
|
||||||
await loadRecentChanges('ctx admin/1');
|
|
||||||
|
|
||||||
expect(getJson).toHaveBeenCalledWith('/api/restaurant/admin/changes?contextId=ctx%20admin%2F1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('posts service window payload', async () => {
|
it('posts service window payload', async () => {
|
||||||
vi.mocked(postJson).mockResolvedValue({ applied: true });
|
vi.mocked(postJson).mockResolvedValue({ applied: true });
|
||||||
|
|
||||||
|
|||||||
@ -1,39 +1,8 @@
|
|||||||
import { getJson, postJson } from './client';
|
import { getJson, postJson } from './client';
|
||||||
|
|
||||||
export type FeatureFlagState = {
|
|
||||||
key: string;
|
|
||||||
enabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceWindow = {
|
|
||||||
day: number;
|
|
||||||
openAt: string;
|
|
||||||
closeAt: string;
|
|
||||||
isClosed: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ConfigChange = {
|
|
||||||
changeId: string;
|
|
||||||
category: string;
|
|
||||||
description: string;
|
|
||||||
updatedBy: string;
|
|
||||||
updatedAtUtc: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RestaurantAdminConfigResponse = {
|
export type RestaurantAdminConfigResponse = {
|
||||||
contextId: string;
|
contextId: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
version: string;
|
|
||||||
featureFlags: FeatureFlagState[];
|
|
||||||
serviceWindows: ServiceWindow[];
|
|
||||||
recentChanges: ConfigChange[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RecentAdminChangesResponse = {
|
|
||||||
contextId: string;
|
|
||||||
summary: string;
|
|
||||||
version: string;
|
|
||||||
recentChanges: ConfigChange[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SetServiceWindowRequest = {
|
export type SetServiceWindowRequest = {
|
||||||
@ -48,17 +17,12 @@ export type SetServiceWindowResponse = {
|
|||||||
contextId: string;
|
contextId: string;
|
||||||
applied: boolean;
|
applied: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
serviceWindow: ServiceWindow;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadDashboard(contextId: string): Promise<RestaurantAdminConfigResponse> {
|
export async function loadDashboard(contextId: string): Promise<RestaurantAdminConfigResponse> {
|
||||||
return getJson<RestaurantAdminConfigResponse>(`/api/restaurant/admin/config?contextId=${encodeURIComponent(contextId)}`);
|
return getJson<RestaurantAdminConfigResponse>(`/api/restaurant/admin/config?contextId=${encodeURIComponent(contextId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadRecentChanges(contextId: string): Promise<RecentAdminChangesResponse> {
|
|
||||||
return getJson<RecentAdminChangesResponse>(`/api/restaurant/admin/changes?contextId=${encodeURIComponent(contextId)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setServiceWindow(request: SetServiceWindowRequest): Promise<SetServiceWindowResponse> {
|
export async function setServiceWindow(request: SetServiceWindowRequest): Promise<SetServiceWindowResponse> {
|
||||||
return postJson<SetServiceWindowResponse>('/api/restaurant/admin/service-window', request);
|
return postJson<SetServiceWindowResponse>('/api/restaurant/admin/service-window', request);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user