You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

454 lines
13KB

  1. import {
  2. CheckCircleFilled,
  3. ClockCircleFilled,
  4. CloseCircleFilled,
  5. ExclamationCircleFilled,
  6. InfoCircleFilled,
  7. LogoutOutlined,
  8. MenuFoldOutlined,
  9. MenuUnfoldOutlined,
  10. } from '@ant-design/icons'
  11. import {
  12. Alert,
  13. Badge,
  14. Button,
  15. Layout,
  16. Menu,
  17. Popconfirm,
  18. Space,
  19. Table,
  20. Tag,
  21. Tooltip,
  22. Typography,
  23. theme,
  24. type TableProps,
  25. } from 'antd'
  26. import { useEffect, useState, type CSSProperties } from 'react'
  27. import {
  28. isEditingAvailable,
  29. isRightPanelCollapsible,
  30. semanticStatusColors,
  31. statusDefinitions,
  32. workspaceThemeTokens,
  33. type WorkspaceStatus,
  34. } from './workspaceContracts'
  35. import type { AuthenticatedUser } from '../auth/authContracts'
  36. import { LegacySchemaCheckPanel } from '../admin/LegacySchemaCheckPanel'
  37. import { MunicipalityProfilePanel } from '../municipalities/MunicipalityProfilePanel'
  38. import {
  39. fetchLegacySchemaCheckHistory,
  40. runLegacySchemaCheck,
  41. } from '../admin/legacySchemaContracts'
  42. import './WorkspaceShell.css'
  43. const { Header, Sider, Content } = Layout
  44. const { Text, Title } = Typography
  45. type CycleRow = {
  46. key: string
  47. municipality: string
  48. cycle: string
  49. service: string
  50. status: WorkspaceStatus
  51. dueDate: string
  52. owner: string
  53. }
  54. const cycleRows: CycleRow[] = [
  55. {
  56. key: 'CAM-1042',
  57. municipality: 'Fairview Borough',
  58. cycle: 'Primary 2026',
  59. service: 'Addressing',
  60. status: 'onTrack',
  61. dueDate: 'May 18',
  62. owner: 'Client Services',
  63. },
  64. {
  65. key: 'CAM-1088',
  66. municipality: 'Lake Township',
  67. cycle: 'Primary 2026',
  68. service: 'Transport',
  69. status: 'atRisk',
  70. dueDate: 'May 12',
  71. owner: 'Production Lead',
  72. },
  73. {
  74. key: 'CAM-1120',
  75. municipality: 'Northfield City',
  76. cycle: 'Special 2026',
  77. service: 'Sorting',
  78. status: 'blocked',
  79. dueDate: 'May 9',
  80. owner: 'Data Quality',
  81. },
  82. {
  83. key: 'CAM-1137',
  84. municipality: 'Pine County',
  85. cycle: 'General 2026',
  86. service: 'Office Copies',
  87. status: 'inProgress',
  88. dueDate: 'May 22',
  89. owner: 'Operations',
  90. },
  91. ]
  92. const riskItems = [
  93. {
  94. id: 'RQ-18',
  95. title: 'Lake Township transport confirmation',
  96. status: 'atRisk' as WorkspaceStatus,
  97. owner: 'Production Lead',
  98. },
  99. {
  100. id: 'RQ-21',
  101. title: 'Northfield missing sorting owner',
  102. status: 'blocked' as WorkspaceStatus,
  103. owner: 'Data Quality',
  104. },
  105. {
  106. id: 'RQ-24',
  107. title: 'Pine County proof approval check',
  108. status: 'inProgress' as WorkspaceStatus,
  109. owner: 'Operations',
  110. },
  111. ]
  112. const statusIcons = {
  113. onTrack: CheckCircleFilled,
  114. atRisk: ExclamationCircleFilled,
  115. blocked: CloseCircleFilled,
  116. inProgress: InfoCircleFilled,
  117. overdue: ClockCircleFilled,
  118. } satisfies Record<WorkspaceStatus, typeof CheckCircleFilled>
  119. function useViewportWidth() {
  120. const [width, setWidth] = useState(() =>
  121. typeof window === 'undefined' ? 1600 : window.innerWidth,
  122. )
  123. useEffect(() => {
  124. const updateWidth = () => setWidth(window.innerWidth)
  125. updateWidth()
  126. window.addEventListener('resize', updateWidth)
  127. return () => window.removeEventListener('resize', updateWidth)
  128. }, [])
  129. return width
  130. }
  131. function StatusIndicator({ status }: { status: WorkspaceStatus }) {
  132. const definition = statusDefinitions[status]
  133. const Icon = statusIcons[status]
  134. return (
  135. <Tag
  136. className="workspace-status"
  137. style={{
  138. borderColor: definition.color,
  139. color: definition.color,
  140. }}
  141. aria-label={`${definition.iconLabel}: ${definition.label}`}
  142. >
  143. <Icon aria-hidden="true" />
  144. <span>{definition.label}</span>
  145. </Tag>
  146. )
  147. }
  148. function WorkspaceNavigation({
  149. user,
  150. selectedKey,
  151. onSelect,
  152. }: {
  153. user: AuthenticatedUser
  154. selectedKey: string
  155. onSelect: (key: string) => void
  156. }) {
  157. const menuItems = [
  158. user.permissions.canViewMunicipalityProfile
  159. ? { key: 'municipalities', label: 'Municipalities' }
  160. : null,
  161. user.permissions.canCreateElectionCycle
  162. ? { key: 'cycles', label: 'Election Cycles' }
  163. : null,
  164. user.permissions.canViewProductionQueue
  165. ? { key: 'production', label: 'Production' }
  166. : null,
  167. user.permissions.canAccessTransportation
  168. ? { key: 'transportation', label: 'Transportation' }
  169. : null,
  170. user.permissions.canAccessSupport ? { key: 'support', label: 'Support' } : null,
  171. user.permissions.canAccessAdmin ? { key: 'admin', label: 'Admin' } : null,
  172. { key: 'reports', label: 'Reports' },
  173. ].filter((item): item is { key: string; label: string } => item !== null)
  174. return (
  175. <Sider width={248} className="workspace-nav" theme="light">
  176. <div className="workspace-brand">
  177. <Text className="workspace-brand__eyebrow">Campaign Tracker</Text>
  178. <Title level={1}>Operations</Title>
  179. </div>
  180. <Menu
  181. mode="inline"
  182. selectedKeys={[selectedKey]}
  183. items={menuItems}
  184. onSelect={({ key }) => onSelect(key)}
  185. />
  186. </Sider>
  187. )
  188. }
  189. function RiskPanel({
  190. collapsed,
  191. onToggle,
  192. canCollapse,
  193. }: {
  194. collapsed: boolean
  195. onToggle: () => void
  196. canCollapse: boolean
  197. }) {
  198. if (collapsed) {
  199. return (
  200. <Sider width={48} className="workspace-inspector workspace-inspector--rail">
  201. <Tooltip title="Open risk panel" placement="left">
  202. <Button
  203. type="text"
  204. icon={<MenuUnfoldOutlined />}
  205. aria-label="Open risk panel"
  206. onClick={onToggle}
  207. />
  208. </Tooltip>
  209. </Sider>
  210. )
  211. }
  212. return (
  213. <Sider width={336} className="workspace-inspector" theme="light">
  214. <div className="workspace-inspector__header">
  215. <div>
  216. <Text className="workspace-kicker">Risk queue</Text>
  217. <Title level={2}>Cutoff Watch</Title>
  218. </div>
  219. {canCollapse ? (
  220. <Tooltip title="Collapse risk panel" placement="left">
  221. <Button
  222. type="text"
  223. icon={<MenuFoldOutlined />}
  224. aria-label="Collapse risk panel"
  225. onClick={onToggle}
  226. />
  227. </Tooltip>
  228. ) : null}
  229. </div>
  230. <Space direction="vertical" size={8} className="workspace-risk-list">
  231. {riskItems.map((item) => (
  232. <article className="workspace-risk-item" key={item.id}>
  233. <div>
  234. <Text className="workspace-risk-item__id">{item.id}</Text>
  235. <Text strong>{item.title}</Text>
  236. </div>
  237. <StatusIndicator status={item.status} />
  238. <Text type="secondary">{item.owner}</Text>
  239. </article>
  240. ))}
  241. </Space>
  242. </Sider>
  243. )
  244. }
  245. export function WorkspaceShell({
  246. user,
  247. onLogout,
  248. adminFetch,
  249. }: {
  250. user: AuthenticatedUser
  251. onLogout: () => Promise<void>
  252. adminFetch: typeof fetch
  253. }) {
  254. const width = useViewportWidth()
  255. const editingAvailable = isEditingAvailable(width)
  256. const canCollapseRightPanel = isRightPanelCollapsible(width)
  257. const [rightPanelCollapseRequested, setRightPanelCollapseRequested] =
  258. useState(false)
  259. const rightPanelCollapsed =
  260. canCollapseRightPanel && rightPanelCollapseRequested
  261. const { token } = theme.useToken()
  262. const initialView = user.permissions.canViewMunicipalityProfile
  263. ? 'municipalities'
  264. : user.permissions.canCreateElectionCycle
  265. ? 'cycles'
  266. : user.permissions.canViewProductionQueue
  267. ? 'production'
  268. : user.permissions.canAccessTransportation
  269. ? 'transportation'
  270. : user.permissions.canAccessSupport
  271. ? 'support'
  272. : user.permissions.canAccessAdmin
  273. ? 'admin'
  274. : 'reports'
  275. const [selectedView, setSelectedView] = useState<string>(initialView)
  276. const columns: TableProps<CycleRow>['columns'] = [
  277. {
  278. title: 'Record',
  279. dataIndex: 'key',
  280. key: 'key',
  281. render: (value: string) => <Text code>{value}</Text>,
  282. },
  283. {
  284. title: 'Municipality',
  285. dataIndex: 'municipality',
  286. key: 'municipality',
  287. },
  288. {
  289. title: 'Cycle',
  290. dataIndex: 'cycle',
  291. key: 'cycle',
  292. },
  293. {
  294. title: 'Service',
  295. dataIndex: 'service',
  296. key: 'service',
  297. },
  298. {
  299. title: 'Status',
  300. dataIndex: 'status',
  301. key: 'status',
  302. render: (status: WorkspaceStatus) => <StatusIndicator status={status} />,
  303. },
  304. {
  305. title: 'Due',
  306. dataIndex: 'dueDate',
  307. key: 'dueDate',
  308. },
  309. {
  310. title: 'Owner',
  311. dataIndex: 'owner',
  312. key: 'owner',
  313. },
  314. ]
  315. return (
  316. <Layout
  317. className="workspace-shell"
  318. style={
  319. {
  320. '--workspace-secondary': semanticStatusColors.secondary,
  321. '--workspace-focus': workspaceThemeTokens.colorInfo,
  322. '--workspace-border': workspaceThemeTokens.colorBorder,
  323. '--workspace-surface': '#FFFFFF',
  324. '--workspace-text-secondary': workspaceThemeTokens.colorTextSecondary,
  325. } as CSSProperties
  326. }
  327. >
  328. <WorkspaceNavigation user={user} selectedKey={selectedView} onSelect={setSelectedView} />
  329. <Layout className="workspace-main">
  330. <Header className="workspace-header">
  331. <Space align="center" size={12}>
  332. <Badge color={workspaceThemeTokens.colorPrimary} text="Primary workspace" />
  333. <Text type="secondary">{user.userName}</Text>
  334. </Space>
  335. <Space>
  336. <Button disabled={!editingAvailable}>Save View</Button>
  337. <Tooltip
  338. title={
  339. editingAvailable && user.permissions.canCreateElectionCycle
  340. ? 'Commit selected operational updates'
  341. : user.permissions.canCreateElectionCycle
  342. ? 'Use a 1280px or wider desktop viewport for editing'
  343. : 'Your Keycloak role does not allow election-cycle updates'
  344. }
  345. >
  346. <Button
  347. type="primary"
  348. disabled={!editingAvailable || !user.permissions.canCreateElectionCycle}
  349. >
  350. Commit Update
  351. </Button>
  352. </Tooltip>
  353. <Popconfirm
  354. title="Log Out"
  355. description="Are you sure you want to end your session?"
  356. onConfirm={onLogout}
  357. okText="Log Out"
  358. cancelText="Cancel"
  359. okButtonProps={{ danger: true }}
  360. >
  361. <Button icon={<LogoutOutlined aria-hidden="true" />} aria-label="Log out of Campaign Tracker">
  362. Log Out
  363. </Button>
  364. </Popconfirm>
  365. </Space>
  366. </Header>
  367. <Content className="workspace-content">
  368. {!editingAvailable ? (
  369. <Alert
  370. className="workspace-support-notice"
  371. type="info"
  372. showIcon
  373. message="Reduced read mode"
  374. description="This viewport is below the 1280px operational minimum. Review is available, but editing and commit actions are disabled until the workspace is opened on a supported desktop width."
  375. />
  376. ) : null}
  377. {selectedView === 'admin' && user.permissions.canAccessAdmin ? (
  378. <LegacySchemaCheckPanel
  379. loadHistory={() => fetchLegacySchemaCheckHistory(adminFetch)}
  380. runCheck={() => runLegacySchemaCheck(adminFetch)}
  381. />
  382. ) : selectedView === 'municipalities' && user.permissions.canViewMunicipalityProfile ? (
  383. <MunicipalityProfilePanel />
  384. ) : (
  385. <section
  386. className="workspace-board"
  387. aria-label="Election cycle operations workspace"
  388. >
  389. <div className="workspace-board__header">
  390. <div>
  391. <Text className="workspace-kicker">Election cycle setup</Text>
  392. <Title level={2}>Municipality work queue</Title>
  393. </div>
  394. <Space size={8} wrap>
  395. <StatusIndicator status="onTrack" />
  396. <StatusIndicator status="atRisk" />
  397. <StatusIndicator status="blocked" />
  398. </Space>
  399. </div>
  400. <Table
  401. className="workspace-table"
  402. columns={columns}
  403. dataSource={cycleRows}
  404. pagination={false}
  405. size="small"
  406. scroll={{ x: 960 }}
  407. rowClassName={(row) =>
  408. row.status === 'blocked' ? 'workspace-row--blocked' : ''
  409. }
  410. />
  411. <div className="workspace-board__footer">
  412. <Text type="secondary">
  413. Last refreshed 12:48 PM. Legacy source context remains read-only;
  414. new workflow updates route through extension records.
  415. </Text>
  416. <Button
  417. style={{ borderColor: token.colorBorder }}
  418. disabled={!editingAvailable || !user.permissions.canViewMunicipalityProfile}
  419. >
  420. Open Inspector
  421. </Button>
  422. </div>
  423. </section>
  424. )}
  425. </Content>
  426. </Layout>
  427. <RiskPanel
  428. collapsed={rightPanelCollapsed}
  429. canCollapse={canCollapseRightPanel}
  430. onToggle={() => setRightPanelCollapseRequested((value) => !value)}
  431. />
  432. </Layout>
  433. )
  434. }

Powered by TurnKey Linux.