474 lines
18 KiB
TypeScript
474 lines
18 KiB
TypeScript
import {
|
|
ControlOutlined,
|
|
DeploymentUnitOutlined,
|
|
HistoryOutlined,
|
|
ReloadOutlined,
|
|
SettingOutlined
|
|
} from '@ant-design/icons';
|
|
import {
|
|
Alert,
|
|
Button,
|
|
Card,
|
|
Descriptions,
|
|
Empty,
|
|
Form,
|
|
Input,
|
|
Layout,
|
|
Menu,
|
|
Result,
|
|
Select,
|
|
Space,
|
|
Spin,
|
|
Table,
|
|
Tag,
|
|
TimePicker,
|
|
Typography
|
|
} from 'antd';
|
|
import dayjs, { type Dayjs } from 'dayjs';
|
|
import { type ReactNode, useMemo, useState } from 'react';
|
|
import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
|
import { ApiError } from './api/client';
|
|
import {
|
|
loadDashboard,
|
|
loadRecentChanges,
|
|
setServiceWindow,
|
|
type ConfigChange,
|
|
type FeatureFlagState,
|
|
type RecentAdminChangesResponse,
|
|
type RestaurantAdminConfigResponse,
|
|
type ServiceWindow,
|
|
type SetServiceWindowRequest,
|
|
type SetServiceWindowResponse
|
|
} from './api/dashboardApi';
|
|
import type { IdentityProvider } from './api/sessionApi';
|
|
import { buildGoogleOidcStartUrl } from './auth/oidcLogin';
|
|
import { SessionProvider, useSessionContext } from './auth/sessionContext';
|
|
|
|
type AppRoute = '/config' | '/window' | '/session';
|
|
|
|
type WorkflowState = {
|
|
error: string | null;
|
|
sessionExpired: boolean;
|
|
};
|
|
|
|
type ServiceWindowFormValues = {
|
|
contextId: string;
|
|
day: number;
|
|
openAt: Dayjs;
|
|
closeAt: Dayjs;
|
|
updatedBy: string;
|
|
};
|
|
|
|
const routeItems: Array<{ key: AppRoute; label: string; icon: ReactNode }> = [
|
|
{ key: '/config', label: 'Config', icon: <SettingOutlined /> },
|
|
{ key: '/window', label: 'Service Window', icon: <ControlOutlined /> },
|
|
{ key: '/session', label: 'Session', icon: <DeploymentUnitOutlined /> }
|
|
];
|
|
|
|
const dayOptions = [
|
|
{ label: 'Sunday', value: 0 },
|
|
{ label: 'Monday', value: 1 },
|
|
{ label: 'Tuesday', value: 2 },
|
|
{ label: 'Wednesday', value: 3 },
|
|
{ label: 'Thursday', value: 4 },
|
|
{ label: 'Friday', value: 5 },
|
|
{ label: 'Saturday', value: 6 }
|
|
];
|
|
|
|
function App() {
|
|
return (
|
|
<SessionProvider>
|
|
<BrowserRouter>
|
|
<RestaurantAdminShell />
|
|
</BrowserRouter>
|
|
</SessionProvider>
|
|
);
|
|
}
|
|
|
|
function RestaurantAdminShell() {
|
|
const session = useSessionContext();
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
|
|
const [contextId, setContextId] = useState('demo-context');
|
|
const [configPayload, setConfigPayload] = useState<RestaurantAdminConfigResponse | null>(null);
|
|
const [changesPayload, setChangesPayload] = useState<RecentAdminChangesResponse | null>(null);
|
|
const [windowResponse, setWindowResponse] = useState<SetServiceWindowResponse | null>(null);
|
|
const [windowHistory, setWindowHistory] = useState<SetServiceWindowResponse[]>([]);
|
|
const [workflowState, setWorkflowState] = useState<WorkflowState>({ error: null, sessionExpired: false });
|
|
const [loadingConfig, setLoadingConfig] = useState(false);
|
|
const [loadingChanges, setLoadingChanges] = useState(false);
|
|
const [submittingWindow, setSubmittingWindow] = useState(false);
|
|
|
|
const loginUrl = useMemo(() => buildGoogleOidcStartUrl(window.location.href), []);
|
|
const selectedKey = useMemo(() => {
|
|
const candidate = routeItems.find((item) => location.pathname.startsWith(item.key));
|
|
return candidate?.key ?? '/config';
|
|
}, [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 () => {
|
|
setLoadingConfig(true);
|
|
clearWorkflowError();
|
|
try {
|
|
const payload = await loadDashboard(contextId);
|
|
setConfigPayload(payload);
|
|
} catch (err) {
|
|
await handleWorkflowFailure(err, 'Failed to load restaurant admin configuration.');
|
|
} finally {
|
|
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) => {
|
|
setSubmittingWindow(true);
|
|
clearWorkflowError();
|
|
|
|
const request: SetServiceWindowRequest = {
|
|
contextId: values.contextId,
|
|
day: values.day,
|
|
openAt: `${values.openAt.format('HH:mm')}:00`,
|
|
closeAt: `${values.closeAt.format('HH:mm')}:00`,
|
|
updatedBy: values.updatedBy
|
|
};
|
|
|
|
try {
|
|
const payload = await setServiceWindow(request);
|
|
setWindowResponse(payload);
|
|
setWindowHistory((previous) => [payload, ...previous].slice(0, 8));
|
|
await loadConfig();
|
|
await refreshChanges();
|
|
} catch (err) {
|
|
await handleWorkflowFailure(err, 'Failed to set service window.');
|
|
} finally {
|
|
setSubmittingWindow(false);
|
|
}
|
|
};
|
|
|
|
if (session.status === 'loading') {
|
|
return (
|
|
<div className="fullscreen-center">
|
|
<Spin size="large" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (session.status !== 'authenticated' || !session.profile) {
|
|
return (
|
|
<main className="app">
|
|
<Result
|
|
status="403"
|
|
title="Authentication Required"
|
|
subTitle="Sign in through the central auth host to access restaurant administration workflows."
|
|
extra={
|
|
<Button type="primary" href={loginUrl}>
|
|
Continue with Google
|
|
</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} />}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Layout className="full-layout">
|
|
<Layout.Sider width={240} breakpoint="lg" collapsedWidth={0}>
|
|
<div className="brand">Restaurant Admin Web</div>
|
|
<Menu
|
|
mode="inline"
|
|
selectedKeys={[selectedKey]}
|
|
items={routeItems}
|
|
onClick={(event) => navigate(event.key as AppRoute)}
|
|
/>
|
|
</Layout.Sider>
|
|
<Layout>
|
|
<Layout.Header className="header">
|
|
<Space wrap>
|
|
<Tag color="blue">subject: {session.profile.subjectId}</Tag>
|
|
<Tag color="geekblue">tenant: {session.profile.tenantId}</Tag>
|
|
<Tag color="purple">provider: {providerLabel(session.profile.provider)}</Tag>
|
|
</Space>
|
|
<Space>
|
|
<Button icon={<ReloadOutlined />} onClick={() => void session.refresh()}>
|
|
Refresh Session
|
|
</Button>
|
|
<Button danger onClick={() => void session.logout()}>
|
|
Logout
|
|
</Button>
|
|
</Space>
|
|
</Layout.Header>
|
|
<Layout.Content className="content">
|
|
<Typography.Title level={3}>Restaurant Administration</Typography.Title>
|
|
<Typography.Paragraph type="secondary">
|
|
Protected control-plane workflows for configuration snapshots, change history, and service-window management.
|
|
</Typography.Paragraph>
|
|
{session.error && <Alert className="stack-gap" type="warning" showIcon message={session.error} />}
|
|
{workflowState.error && <Alert className="stack-gap" type="error" showIcon message={workflowState.error} />}
|
|
{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>
|
|
<Route
|
|
path="/config"
|
|
element={
|
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
|
<Card title="Configuration Snapshot">
|
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
|
<Space wrap>
|
|
<Input
|
|
value={contextId}
|
|
onChange={(event) => setContextId(event.target.value)}
|
|
placeholder="Context Id"
|
|
style={{ width: 280 }}
|
|
/>
|
|
<Button type="primary" loading={loadingConfig} onClick={() => void loadConfig()}>
|
|
Load Config
|
|
</Button>
|
|
<Button icon={<HistoryOutlined />} loading={loadingChanges} onClick={() => void refreshChanges()}>
|
|
Load Recent Changes
|
|
</Button>
|
|
</Space>
|
|
{configPayload ? (
|
|
<Descriptions bordered size="small" column={1}>
|
|
<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>
|
|
) : (
|
|
<Empty description="Load a configuration snapshot to inspect flags and service windows." />
|
|
)}
|
|
</Space>
|
|
</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
|
|
path="/window"
|
|
element={
|
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
|
<Card title="Service Window Action">
|
|
<Form<ServiceWindowFormValues>
|
|
layout="vertical"
|
|
initialValues={{
|
|
contextId,
|
|
day: 1,
|
|
openAt: dayjs('08:00', 'HH:mm'),
|
|
closeAt: dayjs('22:00', 'HH:mm'),
|
|
updatedBy: 'admin-operator'
|
|
}}
|
|
onFinish={(values) => void applyServiceWindow(values)}
|
|
>
|
|
<Form.Item name="contextId" label="Context Id" rules={[{ required: true }]}>
|
|
<Input placeholder="Context Id" />
|
|
</Form.Item>
|
|
<Form.Item name="day" label="Day Of Week" rules={[{ required: true }]}>
|
|
<Select options={dayOptions} />
|
|
</Form.Item>
|
|
<Form.Item name="openAt" label="Open At" rules={[{ required: true }]}>
|
|
<TimePicker format="HH:mm" style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item name="closeAt" label="Close At" rules={[{ required: true }]}>
|
|
<TimePicker format="HH:mm" style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item name="updatedBy" label="Updated By" rules={[{ required: true }]}>
|
|
<Input placeholder="Operator Id" />
|
|
</Form.Item>
|
|
<Button type="primary" htmlType="submit" loading={submittingWindow}>
|
|
Apply Service Window
|
|
</Button>
|
|
</Form>
|
|
</Card>
|
|
|
|
<Card title="Latest Result">
|
|
{windowResponse ? (
|
|
<Descriptions bordered size="small" column={1}>
|
|
<Descriptions.Item label="Context Id">{windowResponse.contextId}</Descriptions.Item>
|
|
<Descriptions.Item label="Applied">{String(windowResponse.applied)}</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>
|
|
) : (
|
|
<Empty description="Submit a service window change to inspect the latest applied snapshot." />
|
|
)}
|
|
</Card>
|
|
|
|
<Card title="Recent Service Window Actions">
|
|
<Table<SetServiceWindowResponse>
|
|
pagination={false}
|
|
rowKey={(record) =>
|
|
`${record.contextId}-${record.message}-${record.serviceWindow.day}-${record.serviceWindow.openAt}`
|
|
}
|
|
dataSource={windowHistory}
|
|
locale={{ emptyText: 'No service window actions submitted yet.' }}
|
|
columns={[
|
|
{ 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',
|
|
render: (_, record) => <Tag color={record.applied ? 'green' : 'red'}>{String(record.applied)}</Tag>
|
|
},
|
|
{ title: 'Message', dataIndex: 'message' }
|
|
]}
|
|
/>
|
|
</Card>
|
|
</Space>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/session"
|
|
element={
|
|
<Card title="Session Details">
|
|
<pre>{JSON.stringify(session.profile, null, 2)}</pre>
|
|
</Card>
|
|
}
|
|
/>
|
|
<Route path="/" element={<Navigate to="/config" replace />} />
|
|
<Route path="*" element={<Navigate to="/config" replace />} />
|
|
</Routes>
|
|
</Layout.Content>
|
|
</Layout>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
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 {
|
|
if (provider === 0 || provider === '0' || provider === 'InternalJwt') {
|
|
return 'Internal JWT';
|
|
}
|
|
|
|
if (provider === 1 || provider === '1' || provider === 'AzureAd') {
|
|
return 'Azure AD';
|
|
}
|
|
|
|
if (provider === 2 || provider === '2' || provider === 'Google') {
|
|
return 'Google';
|
|
}
|
|
|
|
return String(provider);
|
|
}
|
|
|
|
export default App;
|