From 42eedc1c4913ba05577c882fe41beb78a608b1a3 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 13 Jul 2026 16:47:17 -0400 Subject: [PATCH 1/3] feat(web): adopt SHOC design system and shell layout (ADR 0003) Port shoc-frontend-new dev's design system with its CSS-variable single-token-source mechanism: - src/styles/theme.css: SHOC token file ported verbatim (Montserrat/ DM Sans/JetBrains Mono, primary #1c75bc, navy #262262, full radius/ shadow/sidebar/header token layers); fonts self-hosted via @fontsource - src/lib/theme/{css-vars,mui-theme}.ts: getCssVar -> createTheme adapter mirroring SHOC's mui-theme.ts (palette, typography, shadows tuple, component overrides; MUI v9 slot renames expressed as class selectors); theme.ts is now a re-export - Shell: SHOC composition (sidebar column + sticky gradient topbar + scrolling main); sidebar 244px/76px collapse with brand header row, grouped nav, SHOC active treatment (white card + 3px accent bar); topbar 100-degree gradient, surface hamburger, gradient avatar pill - Brand: SeahavenMark + BrandLockup ported (Tailwind re-expressed as sx; wordmark subtitle localized to PROPOSAL SYSTEM) - Login: SHOC auth-card treatment (centered 384px card on #f9fafb) - Old "Sea Haven Ops" Inter/#2563EB theme and Nunito remnants removed; remaining hardcoded hexes replaced with tokens; lucide-react for shell/nav icons per SHOC convention Verify: tsc clean, 26/26 vitest, vite build OK; Playwright screenshots pixel-sampled against the extracted SHOC spec (all hard values exact, no blocking deviations). --- web/index.html | 3 - web/package-lock.json | 40 ++ web/package.json | 4 + web/src/App.tsx | 36 +- web/src/components/Sidebar.tsx | 289 ++++++++------ web/src/components/Topbar.tsx | 129 ++++--- web/src/components/brand/BrandLockup.tsx | 68 ++++ web/src/components/brand/SeahavenMark.tsx | 21 ++ web/src/constants/index.ts | 2 +- web/src/index.css | 13 +- web/src/lib/theme/css-vars.ts | 32 ++ web/src/lib/theme/mui-theme.ts | 295 +++++++++++++++ web/src/main.tsx | 1 + .../pages/admin/dashboard/AdminDashboard.tsx | 2 +- .../pages/admin/workspace/AdminWorkspace.tsx | 2 +- web/src/pages/auth/LoginPage.tsx | 246 ++++++------ web/src/pages/dashboard/Dashboard.tsx | 4 +- web/src/styles/fonts.css | 12 + web/src/styles/globals.css | 5 + web/src/styles/motion.css | 6 + web/src/styles/theme.css | 188 +++++++++ web/src/styles/typography.css | 52 +++ web/src/theme.ts | 357 +----------------- 23 files changed, 1134 insertions(+), 673 deletions(-) create mode 100644 web/src/components/brand/BrandLockup.tsx create mode 100644 web/src/components/brand/SeahavenMark.tsx create mode 100644 web/src/lib/theme/css-vars.ts create mode 100644 web/src/lib/theme/mui-theme.ts create mode 100644 web/src/styles/fonts.css create mode 100644 web/src/styles/globals.css create mode 100644 web/src/styles/motion.css create mode 100644 web/src/styles/theme.css create mode 100644 web/src/styles/typography.css diff --git a/web/index.html b/web/index.html index 62fb501..7b7a1a9 100644 --- a/web/index.html +++ b/web/index.html @@ -4,9 +4,6 @@ Proposal System - Sea Haven Industries - - -
diff --git a/web/package-lock.json b/web/package-lock.json index f0b34a7..60df75c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,11 +10,15 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@fontsource/dm-sans": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", + "@fontsource/montserrat": "^5.2.8", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.1.1", "@reduxjs/toolkit": "^2.11.2", "@tanstack/react-query": "^5.101.2", "axios": "^1.18.1", + "lucide-react": "^1.24.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-redux": "^9.2.0", @@ -582,6 +586,33 @@ } } }, + "node_modules/@fontsource/dm-sans": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz", + "integrity": "sha512-tlovG42m9ESG28WiHpLq3F5umAlm64rv0RkqTbYowRn70e9OlRr5a3yTJhrhrY+k5lftR/OFJjPzOLQzk8EfCA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/montserrat": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.2.8.tgz", + "integrity": "sha512-xTjLxSbSfCycDB0pwmNsfNvdfWPaDaRQ2LC6yt/ZI7SdvXG52zHnzNYC/09mzuAuWNJyShkteutfCoDgym56hQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2943,6 +2974,15 @@ "node": "20 || >=22" } }, + "node_modules/lucide-react": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz", + "integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/web/package.json b/web/package.json index 245c73e..42707cc 100644 --- a/web/package.json +++ b/web/package.json @@ -13,11 +13,15 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@fontsource/dm-sans": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", + "@fontsource/montserrat": "^5.2.8", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.1.1", "@reduxjs/toolkit": "^2.11.2", "@tanstack/react-query": "^5.101.2", "axios": "^1.18.1", + "lucide-react": "^1.24.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-redux": "^9.2.0", diff --git a/web/src/App.tsx b/web/src/App.tsx index 94a14ad..f247d00 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,5 @@ import { Routes, Route, Navigate } from 'react-router-dom'; -import { Box, Button, Card, CardContent, Toolbar, Typography } from '@mui/material'; +import { Box, Button, Card, CardContent, Typography } from '@mui/material'; import PeopleIcon from '@mui/icons-material/People'; import ProtectedRoute, { RoleGuard } from './components/ProtectedRoute'; import Topbar from './components/Topbar'; @@ -25,20 +25,21 @@ export default function App() { path="/*" element={ - - + {/* SHOC shell composition (ADR 0003): sidebar column + [topbar / scrolling main] */} + - - - + + + + } /> } /> } /> @@ -55,7 +56,7 @@ export default function App() { - + User Management Users and roles are currently managed in AWS Cognito. Contact the system administrator to update access. @@ -75,8 +76,9 @@ export default function App() { } /> - } /> - + } /> + + diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 617277b..191c619 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -1,48 +1,45 @@ +// App shell sidebar — SHOC design system port (ADR 0003, mirrors app-sidebar.tsx). +// Expanded 244px / collapsed 76px; brand header row; grouped nav with SHOC +// active treatment (white card bg + shadow + 3px left accent bar). import { useLocation, useNavigate } from 'react-router-dom'; import { useSelector, useDispatch } from 'react-redux'; +import { Drawer, Box, Tooltip, useMediaQuery, useTheme } from '@mui/material'; import { - Drawer, - List, - ListItemButton, - ListItemIcon, - ListItemText, - Toolbar, - Box, - Typography, - Tooltip, - useMediaQuery, - useTheme, -} from '@mui/material'; -import DashboardIcon from '@mui/icons-material/Dashboard'; -import DescriptionIcon from '@mui/icons-material/Description'; -import AddCircleIcon from '@mui/icons-material/AddCircle'; -import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; -import AssignmentIcon from '@mui/icons-material/Assignment'; -import PeopleIcon from '@mui/icons-material/People'; -import BusinessIcon from '@mui/icons-material/Business'; -import MenuBookIcon from '@mui/icons-material/MenuBook'; + LayoutDashboard, + FileText, + CirclePlus, + ClipboardList, + FolderOpen, + Building2, + BookOpen, + Users, + type LucideIcon, +} from 'lucide-react'; import { selectSidebarOpen, setSidebarOpen } from '../app/slices/uiSlice'; import { selectUser } from '../app/slices/authSlice'; import type { RootState } from '../app/store'; import { DRAWER_WIDTH } from '../constants'; +import BrandLockup, { BrandMark } from './brand/BrandLockup'; -const COLLAPSED_WIDTH = 72; +const COLLAPSED_WIDTH = 76; // --spacing-sidebar-collapsed -const dispatcherNav = [ - { label: 'Dashboard', path: '/', icon: }, - { label: 'My Proposals', path: '/proposals', icon: }, - { label: 'New Proposal', path: '/proposals/new', icon: }, +type NavEntry = { label: string; path: string; icon: LucideIcon }; + +const dispatcherNav: NavEntry[] = [ + { label: 'Dashboard', path: '/', icon: LayoutDashboard }, + { label: 'My Proposals', path: '/proposals', icon: FileText }, + { label: 'New Proposal', path: '/proposals/new', icon: CirclePlus }, ]; -const adminNav = [ - { label: 'Admin Queue', path: '/admin', icon: }, - { label: 'All Proposals', path: '/admin/proposals', icon: }, - { label: 'Customers', path: '/admin/customers', icon: }, - { label: 'Pricing Library', path: '/admin/pricing-library', icon: }, +const adminNav: NavEntry[] = [ + { label: 'Admin Queue', path: '/admin', icon: ClipboardList }, + { label: 'All Proposals', path: '/admin/proposals', icon: FolderOpen }, + { label: 'Customers', path: '/admin/customers', icon: Building2 }, + { label: 'Pricing Library', path: '/admin/pricing-library', icon: BookOpen }, ]; -const sysadminNav = [ - { label: 'Users', path: '/admin/users', icon: }, +const sysadminNav: NavEntry[] = [ + { label: 'Users', path: '/admin/users', icon: Users }, ]; const ALL_NAV_PATHS = new Set( @@ -56,6 +53,88 @@ function isNavActive(pathname: string, itemPath: string): boolean { return pathname.startsWith(itemPath + '/'); } +function NavItem({ + item, + active, + collapsed, + onClick, +}: { + item: NavEntry; + active: boolean; + collapsed: boolean; + onClick: () => void; +}) { + const Icon = item.icon; + const row = ( + + {active && ( + + )} + + {!collapsed && ( + + {item.label} + + )} + + ); + + if (collapsed) { + return ( + + {row} + + ); + } + return row; +} + export default function Sidebar() { const navigate = useNavigate(); const location = useLocation(); @@ -65,14 +144,14 @@ export default function Sidebar() { const user = useSelector((state: RootState) => selectUser(state)); const isMobile = useMediaQuery(theme.breakpoints.down('md')); // <900px - const isCollapsed = useMediaQuery(theme.breakpoints.between('md', 'lg')); // 900–1200px const isAdmin = user?.role === 'Admin' || user?.role === 'SysAdmin'; const isSysAdmin = user?.role === 'SysAdmin'; - const navItems = dispatcherNav; - const drawerVariant = isMobile ? 'temporary' : 'persistent'; - const effectiveWidth = isCollapsed ? COLLAPSED_WIDTH : DRAWER_WIDTH; + // Desktop: hamburger toggles expanded (244) ↔ collapsed (76). + // Mobile: temporary drawer, hamburger opens/closes it. + const collapsed = !isMobile && !open; + const width = collapsed ? COLLAPSED_WIDTH : DRAWER_WIDTH; const handleNavClick = (path: string) => { navigate(path); @@ -81,107 +160,69 @@ export default function Sidebar() { } }; - const handleDrawerClose = () => { - dispatch(setSidebarOpen(false)); - }; + const groupLabel = (label: string) => ( + + {label} + + ); - const renderNavItem = (item: { label: string; path: string; icon: React.ReactNode }) => { - const button = ( - handleNavClick(item.path)} - sx={{ - py: 1, - justifyContent: isCollapsed ? 'center' : 'flex-start', - minHeight: 40, - }} - > - - {item.icon} - - {!isCollapsed && ( - - )} - - ); + const groupDivider = ( + + ); - if (isCollapsed) { - return ( - - {button} - - ); - } - - return button; - }; + const renderGroup = (label: string, items: NavEntry[], first: boolean) => ( + <> + {collapsed ? (first ? null : groupDivider) : groupLabel(label)} + {items.map((item) => ( + handleNavClick(item.path)} + /> + ))} + + ); return ( dispatch(setSidebarOpen(false))} sx={{ - width: open ? effectiveWidth : 0, + width: isMobile ? 0 : width, flexShrink: 0, - transition: 'width 250ms ease', '& .MuiDrawer-paper': { - width: effectiveWidth, + width: isMobile ? DRAWER_WIDTH : width, boxSizing: 'border-box', + backgroundColor: 'var(--color-sidebar-bg)', + borderRight: '1px solid var(--color-sidebar-border)', + borderRadius: 0, overflowX: 'hidden', + transition: 'width 200ms var(--ease-default, ease)', }, }} > - - - {isAdmin && ( - <> - {!isCollapsed && ( - - Admin - - )} - - {adminNav.map((item) => renderNavItem(item))} - {isSysAdmin && sysadminNav.map((item) => renderNavItem(item))} - - - )} - {!isCollapsed && ( - - Proposals - - )} - - {navItems.map((item) => renderNavItem(item))} - + {/* Brand header row — 64px, white, aligns with the topbar */} + + {collapsed ? : } + + + + {isAdmin && renderGroup('Admin', [...adminNav, ...(isSysAdmin ? sysadminNav : [])], true)} + {renderGroup('Proposals', dispatcherNav, !isAdmin)} ); diff --git a/web/src/components/Topbar.tsx b/web/src/components/Topbar.tsx index 9a04831..5ba17f6 100644 --- a/web/src/components/Topbar.tsx +++ b/web/src/components/Topbar.tsx @@ -1,7 +1,7 @@ +// App shell top bar — SHOC design system port (ADR 0003, mirrors app-topbar.tsx). import { useDispatch } from 'react-redux'; -import { AppBar, Toolbar, IconButton, Typography, Box, Avatar } from '@mui/material'; -import MenuIcon from '@mui/icons-material/Menu'; -import LogoutIcon from '@mui/icons-material/Logout'; +import { AppBar, Toolbar, IconButton, Box, Avatar, Typography } from '@mui/material'; +import { Menu as MenuIcon, LogOut } from 'lucide-react'; import { toggleSidebar } from '../app/slices/uiSlice'; import { useAuth } from '../hooks/useAuth'; @@ -11,70 +11,103 @@ export default function Topbar() { const initials = user?.displayName ? user.displayName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) - : ''; + : 'U'; const isDevMode = !import.meta.env.VITE_COGNITO_DOMAIN; return ( - theme.zIndex.drawer + 1, height: 64 }}> - + + dispatch(toggleSidebar())} - sx={{ mr: 2 }} aria-label="Toggle sidebar menu" + sx={{ + width: 38, + height: 38, + borderRadius: 'var(--radius-md)', + backgroundColor: 'var(--color-header-surface)', + '&:hover': { backgroundColor: 'var(--color-header-surface-hover)' }, + }} > - + - - Proposal System - {isDevMode && ( - + DEV + + )} + + + + {user && ( + + - DEV + {initials} + + + + {user.displayName} + + + {user.role} + - )} - - {user && ( - - - - {initials} - - - - {user.displayName} - - - {user.role} - - - - - + )} diff --git a/web/src/components/brand/BrandLockup.tsx b/web/src/components/brand/BrandLockup.tsx new file mode 100644 index 0000000..4472ac9 --- /dev/null +++ b/web/src/components/brand/BrandLockup.tsx @@ -0,0 +1,68 @@ +// Sea Haven brand lockup — ported from shoc-frontend-new dev (brand-lockup.tsx), +// Tailwind classes re-expressed as sx (this app is MUI-only per ADR 0003). +// Wordmark subtitle localized to this app: "PROPOSAL SYSTEM". +import { Box } from '@mui/material'; +import SeahavenMark from './SeahavenMark'; + +type BrandLockupSize = 'default' | 'compact'; + +export function BrandMark({ size = 'default' }: { size?: BrandLockupSize }) { + const compact = size === 'compact'; + return ( + + + + ); +} + +export function BrandWordmark({ size = 'default' }: { size?: BrandLockupSize }) { + const compact = size === 'compact'; + return ( + + + SEAHAVEN + + + PROPOSAL SYSTEM + + + ); +} + +export default function BrandLockup({ size = 'default' }: { size?: BrandLockupSize }) { + return ( + + + + + ); +} diff --git a/web/src/components/brand/SeahavenMark.tsx b/web/src/components/brand/SeahavenMark.tsx new file mode 100644 index 0000000..f60a4b1 --- /dev/null +++ b/web/src/components/brand/SeahavenMark.tsx @@ -0,0 +1,21 @@ +// Sea Haven brand mark — ported from shoc-frontend-new dev (seahaven-mark.tsx). +export default function SeahavenMark({ size = 18 }: { size?: number }) { + return ( + + ); +} diff --git a/web/src/constants/index.ts b/web/src/constants/index.ts index 36ebcb3..89b2b60 100644 --- a/web/src/constants/index.ts +++ b/web/src/constants/index.ts @@ -38,7 +38,7 @@ export const STATUS_CHIP_STYLES: Record = { Standard: 'Standard', diff --git a/web/src/index.css b/web/src/index.css index 6ad3d12..9acafa1 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,5 +1,4 @@ -@import url("https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200;0,300;0,400;0,600;0,700;1,400&display=swap"); - +/* App-level base styles. Fonts/tokens/typography live in src/styles/globals.css. */ * { box-sizing: border-box; } @@ -9,16 +8,12 @@ html, body, #root { } body { - margin: 0; - font-family: "Nunito", "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - font-size: 13px; - background: #f4f6f7; - color: #333; - line-height: 1.5; + font-size: var(--text-base); + line-height: var(--leading-normal); } a { - color: #007bff; + color: var(--primary); text-decoration: none; } diff --git a/web/src/lib/theme/css-vars.ts b/web/src/lib/theme/css-vars.ts new file mode 100644 index 0000000..7f73b3d --- /dev/null +++ b/web/src/lib/theme/css-vars.ts @@ -0,0 +1,32 @@ +/** + * CSS custom-property bridge — mirrors shoc-frontend-new dev src/lib/theme/css-vars.ts. + * Reads live tokens from :root at theme-creation time; falls back to the static + * map when document is unavailable (tests, pre-paint). + */ +export function getCssVar(name: string, fallback = ''): string { + if (typeof document === 'undefined') return fallback; + const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return value || fallback; +} + +export const CSS_VAR_FALLBACKS = { + primary: '#1c75bc', + primaryHover: '#155a92', + primaryForeground: '#ffffff', + destructive: '#dc2626', + success: '#00a79d', + warning: '#d97706', + background: '#ffffff', + foreground: '#262262', + border: '#dfe3ea', + muted: '#e6e7e8', + mutedForeground: '#58595b', + inputBackground: '#f9fafb', + ring: '#1c75bc', + bgPage: '#f9fafb', + radius: '8', + headerStart: '#1b1f52', + headerMid: '#1c4f8f', + headerEnd: '#1c75bc', + headerText: '#ffffff', +} as const; diff --git a/web/src/lib/theme/mui-theme.ts b/web/src/lib/theme/mui-theme.ts new file mode 100644 index 0000000..28be2c5 --- /dev/null +++ b/web/src/lib/theme/mui-theme.ts @@ -0,0 +1,295 @@ +import { createTheme } from '@mui/material/styles'; +import type { Shadows } from '@mui/material/styles'; +import { getCssVar, CSS_VAR_FALLBACKS } from './css-vars'; + +/** + * MUI adapter over the CSS-variable token file (src/styles/theme.css). + * Mirrors shoc-frontend-new dev src/lib/theme/mui-theme.ts (ADR 0003). + * Token changes are values-only edits in theme.css — never here. + */ + +declare module '@mui/material/styles' { + interface Palette { + header: Palette['primary']; + } + interface PaletteOptions { + header?: PaletteOptions['primary']; + } +} + +const v = (name: string, fallback: string) => getCssVar(name, fallback); + +const primary = v('--primary', CSS_VAR_FALLBACKS.primary); +const primaryHover = v('--primary-hover', CSS_VAR_FALLBACKS.primaryHover); +const primaryForeground = v('--primary-foreground', CSS_VAR_FALLBACKS.primaryForeground); +const destructive = v('--destructive', CSS_VAR_FALLBACKS.destructive); +const success = v('--success', CSS_VAR_FALLBACKS.success); +const warning = v('--warning', CSS_VAR_FALLBACKS.warning); +const background = v('--background', CSS_VAR_FALLBACKS.background); +const foreground = v('--foreground', CSS_VAR_FALLBACKS.foreground); +const border = v('--border', CSS_VAR_FALLBACKS.border); +const muted = v('--muted', CSS_VAR_FALLBACKS.muted); +const mutedForeground = v('--muted-foreground', CSS_VAR_FALLBACKS.mutedForeground); +const inputBackground = v('--input-background', CSS_VAR_FALLBACKS.inputBackground); +const ring = v('--ring', CSS_VAR_FALLBACKS.ring); +const bgPage = v('--color-bg-page', CSS_VAR_FALLBACKS.bgPage); +const radius = parseInt(v('--radius', `${CSS_VAR_FALLBACKS.radius}px`), 10); +const headerEnd = v('--color-header-bg-end', CSS_VAR_FALLBACKS.headerEnd); +const headerText = v('--color-header-text', CSS_VAR_FALLBACKS.headerText); + +const shadowSm = v('--shadow-sm', '0 1px 3px rgba(22, 30, 56, 0.06), 0 1px 2px rgba(22, 30, 56, 0.04)'); +const shadowMd = v('--shadow-md', '0 4px 6px -1px rgba(22, 30, 56, 0.08), 0 2px 4px -2px rgba(22, 30, 56, 0.06)'); +const shadowLg = v('--shadow-lg', '0 10px 15px -3px rgba(22, 30, 56, 0.1), 0 4px 6px -4px rgba(22, 30, 56, 0.08)'); + +const fontSans = '"DM Sans", ui-sans-serif, system-ui, sans-serif'; +const fontDisplay = '"Montserrat", ui-sans-serif, system-ui, sans-serif'; + +// MUI's 25-slot elevation tuple, fully overridden: 0=none, 1–2=sm, 3–5=md, 6+=lg. +const shadows = [ + 'none', + shadowSm, + shadowSm, + shadowMd, + shadowMd, + shadowMd, + ...Array(19).fill(shadowLg), +] as Shadows; + +export const muiTheme = createTheme({ + palette: { + mode: 'light', + primary: { + main: primary, + dark: primaryHover, + contrastText: primaryForeground, + }, + // Repurposed as the muted/neutral swatch (SHOC convention), not a brand color. + secondary: { + main: muted, + contrastText: foreground, + }, + error: { + main: destructive, + contrastText: '#ffffff', + }, + success: { + main: success, + contrastText: '#ffffff', + }, + warning: { + main: warning, + contrastText: '#ffffff', + }, + background: { + default: bgPage, + paper: background, + }, + text: { + primary: foreground, + secondary: mutedForeground, + }, + divider: border, + header: { + main: headerEnd, + contrastText: headerText, + }, + }, + + typography: { + fontFamily: fontSans, + fontSize: 14, + fontWeightRegular: 400, + fontWeightMedium: 500, + fontWeightBold: 600, + h1: { fontFamily: fontDisplay, fontWeight: 800, fontSize: '1.75rem', lineHeight: 1.25 }, + h2: { fontFamily: fontDisplay, fontWeight: 700, fontSize: '1.375rem', lineHeight: 1.25 }, + h3: { fontFamily: fontDisplay, fontWeight: 600, fontSize: '1.125rem', lineHeight: 1.25 }, + h4: { fontFamily: fontDisplay, fontWeight: 600, fontSize: '1rem', lineHeight: 1.25 }, + h5: { fontFamily: fontDisplay, fontWeight: 600, fontSize: '0.875rem', lineHeight: 1.25 }, + h6: { fontFamily: fontDisplay, fontWeight: 600, fontSize: '0.8125rem', lineHeight: 1.25 }, + subtitle1: { fontWeight: 600, fontSize: '1rem' }, + subtitle2: { fontWeight: 500, fontSize: '0.875rem', color: mutedForeground }, + body1: { fontSize: '0.875rem', lineHeight: 1.5 }, + body2: { fontSize: '0.8125rem', lineHeight: 1.5, color: mutedForeground }, + caption: { fontSize: '0.75rem', color: mutedForeground }, + button: { textTransform: 'none' as const, fontWeight: 600, fontSize: '0.875rem' }, + overline: { + fontSize: '0.6875rem', + fontWeight: 600, + letterSpacing: '0.08em', + textTransform: 'uppercase' as const, + color: mutedForeground, + }, + }, + + shape: { + borderRadius: radius, + }, + + shadows, + + components: { + MuiCssBaseline: { + styleOverrides: { + body: { + fontSize: 14, + backgroundColor: bgPage, + color: foreground, + }, + }, + }, + + MuiButton: { + defaultProps: { + disableElevation: true, + }, + styleOverrides: { + root: { + borderRadius: 'var(--radius-md)', + textTransform: 'none' as const, + fontWeight: 600, + fontSize: '0.875rem', + transition: 'background-color var(--duration-fast) var(--ease-default), box-shadow var(--duration-fast) var(--ease-default)', + '&:focus-visible': { + outline: `2px solid ${ring}`, + outlineOffset: 2, + }, + // MUI v9: containedPrimary slot removed — class selector instead + '&.MuiButton-containedPrimary:hover': { + backgroundColor: primaryHover, + }, + }, + outlined: { + borderColor: border, + '&:hover': { + borderColor: 'var(--border-hover)', + backgroundColor: 'var(--secondary)', + }, + }, + }, + }, + + MuiTextField: { + defaultProps: { + size: 'small' as const, + }, + }, + + MuiOutlinedInput: { + styleOverrides: { + root: { + backgroundColor: inputBackground, + borderRadius: 'var(--radius-md)', + '& .MuiOutlinedInput-notchedOutline': { + borderColor: border, + }, + '&:hover .MuiOutlinedInput-notchedOutline': { + borderColor: 'var(--border-hover)', + }, + '&.Mui-focused': { + boxShadow: `0 0 0 3px color-mix(in srgb, ${ring} 25%, transparent)`, + }, + '&.Mui-focused .MuiOutlinedInput-notchedOutline': { + borderColor: ring, + borderWidth: 2, + }, + }, + input: { + padding: '8.5px 13px', + fontSize: '0.875rem', + '&.MuiOutlinedInput-inputAdornedEnd': { + paddingRight: 40, + }, + }, + }, + }, + + MuiTableCell: { + styleOverrides: { + root: { + fontSize: '0.875rem', + borderColor: border, + padding: '10px 16px', + }, + head: { + fontWeight: 600, + backgroundColor: 'var(--color-bg-muted)', + color: foreground, + }, + }, + }, + + MuiTableRow: { + styleOverrides: { + root: { + '&:last-child .MuiTableCell-root': { + borderBottom: 0, + }, + }, + }, + }, + + MuiChip: { + styleOverrides: { + root: { + borderRadius: 'var(--radius-sm)', + fontWeight: 500, + fontSize: '0.75rem', + }, + outlined: { + borderColor: border, + }, + }, + }, + + MuiPaper: { + defaultProps: { + elevation: 0, + }, + styleOverrides: { + root: { + backgroundImage: 'none', + borderRadius: radius, + }, + outlined: { + borderColor: border, + }, + }, + }, + + MuiDialog: { + styleOverrides: { + paper: { + borderRadius: 'var(--radius-xl)', + boxShadow: shadowLg, + }, + }, + }, + + MuiAlert: { + styleOverrides: { + root: { + borderRadius: 'var(--radius-md)', + fontSize: '0.875rem', + // MUI v9: standard* slots removed — class selectors instead + '&.MuiAlert-standardSuccess': { + backgroundColor: 'var(--status-completed-bg)', + color: 'var(--status-completed-text)', + }, + '&.MuiAlert-standardError': { + backgroundColor: 'var(--doc-no-bg)', + color: 'var(--doc-no-text)', + }, + '&.MuiAlert-standardWarning': { + backgroundColor: 'var(--status-pending-bg)', + color: 'var(--status-pending-text)', + }, + '&.MuiAlert-standardInfo': { + backgroundColor: 'var(--color-primary-tint)', + color: 'var(--sidebar-accent-foreground)', + }, + }, + }, + }, + }, +}); diff --git a/web/src/main.tsx b/web/src/main.tsx index f56fab5..0d0ee59 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -6,6 +6,7 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider, CssBaseline } from '@mui/material'; import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; +import './styles/globals.css'; import './index.css'; import { store } from './app/store'; import { queryClient } from './lib/queryClient'; diff --git a/web/src/pages/admin/dashboard/AdminDashboard.tsx b/web/src/pages/admin/dashboard/AdminDashboard.tsx index bdf4578..b664728 100644 --- a/web/src/pages/admin/dashboard/AdminDashboard.tsx +++ b/web/src/pages/admin/dashboard/AdminDashboard.tsx @@ -56,7 +56,7 @@ function StatCard({ icon, label, value, color, subtitle }: { icon: React.ReactNo {label} - + {value} {subtitle && ( diff --git a/web/src/pages/admin/workspace/AdminWorkspace.tsx b/web/src/pages/admin/workspace/AdminWorkspace.tsx index ff32e7c..87729e8 100644 --- a/web/src/pages/admin/workspace/AdminWorkspace.tsx +++ b/web/src/pages/admin/workspace/AdminWorkspace.tsx @@ -310,7 +310,7 @@ export default function AdminWorkspace() { component="button" underline="hover" onClick={() => guardedNavigate('/admin')} - sx={{ fontSize: 14, color: '#2563EB', cursor: 'pointer' }} + sx={{ fontSize: 14, color: 'var(--primary)', cursor: 'pointer' }} > Admin Queue diff --git a/web/src/pages/auth/LoginPage.tsx b/web/src/pages/auth/LoginPage.tsx index 9f2e7a2..8694088 100644 --- a/web/src/pages/auth/LoginPage.tsx +++ b/web/src/pages/auth/LoginPage.tsx @@ -1,7 +1,9 @@ +// Login — SHOC auth-card treatment (ADR 0003, mirrors AuthPageShell/AuthCardHeader): +// centered 384px card on --color-bg-page, brand lockup, Montserrat heading. import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useDispatch } from 'react-redux'; -import { Alert, Box, Button, Card, CardContent, Typography, Divider, Stack } from '@mui/material'; +import { Alert, Box, Button, Typography, Divider, Stack } from '@mui/material'; import GoogleIcon from '@mui/icons-material/Google'; import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; import EngineeringIcon from '@mui/icons-material/Engineering'; @@ -9,6 +11,7 @@ import PersonIcon from '@mui/icons-material/Person'; import { useAuth } from '../../hooks/useAuth'; import { authApi } from '../../lib/api/auth'; import { setUser, setError } from '../../app/slices/authSlice'; +import BrandLockup from '../../components/brand/BrandLockup'; const COGNITO_DOMAIN = import.meta.env.VITE_COGNITO_DOMAIN || ''; const CLIENT_ID = import.meta.env.VITE_COGNITO_CLIENT_ID || ''; @@ -24,6 +27,12 @@ function buildLoginUrl(): string { return `https://${COGNITO_DOMAIN}/oauth2/authorize?${params.toString()}`; } +const devButtonSx = { + py: 1.5, + height: 56, + justifyContent: 'flex-start', +} as const; + export default function LoginPage() { const navigate = useNavigate(); const dispatch = useDispatch(); @@ -62,126 +71,135 @@ export default function LoginPage() { - - - + + + + + + Welcome back + + + Sign in to your account + + + {error && ( + + {error} + + )} + + {isDevMode ? ( + <> + + + + + + + + Development sign-in — only shown when Cognito is not configured. + + + ) : ( + - - - - - - Dev login is only shown when Cognito is not configured. - - - ) : ( - <> - - Sign in with your Sea Haven Google account to continue. - - - - )} - - - - Sea Haven Industries © {new Date().getFullYear()} + Sign in with Google + + )} + + + Having trouble? Contact your administrator. diff --git a/web/src/pages/dashboard/Dashboard.tsx b/web/src/pages/dashboard/Dashboard.tsx index 3728186..56931a9 100644 --- a/web/src/pages/dashboard/Dashboard.tsx +++ b/web/src/pages/dashboard/Dashboard.tsx @@ -34,11 +34,11 @@ function KpiCard({ icon, label, value, subtitle }: { icon: React.ReactNode; labe return ( - {icon} + {icon} {label} - + {value} {subtitle && ( diff --git a/web/src/styles/fonts.css b/web/src/styles/fonts.css new file mode 100644 index 0000000..bf759c9 --- /dev/null +++ b/web/src/styles/fonts.css @@ -0,0 +1,12 @@ +/* Self-hosted fonts (@fontsource) — same weights as shoc-frontend-new dev. */ +@import "@fontsource/montserrat/600.css"; +@import "@fontsource/montserrat/700.css"; +@import "@fontsource/montserrat/800.css"; + +@import "@fontsource/dm-sans/400.css"; +@import "@fontsource/dm-sans/500.css"; +@import "@fontsource/dm-sans/600.css"; + +@import "@fontsource/jetbrains-mono/400.css"; +@import "@fontsource/jetbrains-mono/500.css"; +@import "@fontsource/jetbrains-mono/600.css"; diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css new file mode 100644 index 0000000..db4a57b --- /dev/null +++ b/web/src/styles/globals.css @@ -0,0 +1,5 @@ +/* Global stylesheet — import order mirrors shoc-frontend-new dev (minus Tailwind). */ +@import "./fonts.css"; +@import "./theme.css"; +@import "./typography.css"; +@import "./motion.css"; diff --git a/web/src/styles/motion.css b/web/src/styles/motion.css new file mode 100644 index 0000000..afba9f4 --- /dev/null +++ b/web/src/styles/motion.css @@ -0,0 +1,6 @@ +/* Motion tokens — mirrors shoc-frontend-new dev src/styles/motion.css. */ +:root { + --duration-fast: 150ms; + --duration-normal: 250ms; + --ease-default: cubic-bezier(0.4, 0, 0.2, 1); +} diff --git a/web/src/styles/theme.css b/web/src/styles/theme.css new file mode 100644 index 0000000..bb1ce7d --- /dev/null +++ b/web/src/styles/theme.css @@ -0,0 +1,188 @@ +/* + * Sea Haven design tokens — single source of truth (ADR 0003). + * Ported verbatim from shoc-frontend-new dev src/styles/theme.css (values-only + * changes belong here; never rewrite the MUI adapter to change a color). + * The SHOC @theme inline Tailwind block is intentionally omitted — this app is MUI-only. + */ +:root { + /* Body typography reference — do not set on html (rem uses 16px root) */ + --font-size: 14px; + + /* ── TYPOGRAPHY ─────────────────────────────────────────────────── */ + --font-family-display: "Montserrat", ui-sans-serif, system-ui, sans-serif; + --font-family-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif; + --font-family-mono: "JetBrains Mono", ui-monospace, monospace; + --font-display: var(--font-family-display); + --font-sans: var(--font-family-sans); + --font-mono: var(--font-family-mono); + --font-heading: var(--font-family-display); + + /* ── TYPOGRAPHY SCALE ───────────────────────────────────────────── */ + --text-xs: 11px; + --text-sm: 12px; + --text-base-sm: 13px; + --text-base: 14px; + --text-md: 16px; + --text-lg: 18px; + --text-xl: 22px; + --text-2xl: 28px; + --leading-tight: 1.25; + --leading-normal: 1.5; + --leading-relaxed: 1.625; + --tracking-tight: -0.01em; + --tracking-normal: 0; + --tracking-wide: 0.05em; + --tracking-wider: 0.08em; + + /* ── LAYOUT SPACING ─────────────────────────────────────────────── */ + --spacing-sidebar: 244px; + --spacing-topbar: 64px; + --spacing-sidebar-collapsed: 76px; + + /* ── RADIUS SCALE (scale resolves to 4 / 6 / 8 / 12) ────────────── */ + --radius: 8px; + --radius-md: 6px; + --radius-sm: 4px; + --radius-xl: 12px; + + /* ── ELEVATION ──────────────────────────────────────────────────── */ + --shadow-sm: 0 1px 3px rgba(22, 30, 56, 0.06), 0 1px 2px rgba(22, 30, 56, 0.04); + --shadow-md: 0 4px 6px -1px rgba(22, 30, 56, 0.08), 0 2px 4px -2px rgba(22, 30, 56, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(22, 30, 56, 0.1), 0 4px 6px -4px rgba(22, 30, 56, 0.08); + --shadow-brand-mark: 0 2px 6px -1px rgba(28, 117, 188, 0.45); + + /* ── SHADCN BASE (Seahaven DS) ──────────────────────────────────── */ + --background: #ffffff; + --foreground: #262262; + --placeholder-foreground: color-mix(in srgb, var(--foreground) 50%, transparent); + --card: #ffffff; + --card-foreground: #262262; + --popover: #ffffff; + --popover-foreground: #262262; + --primary: #1c75bc; + --primary-hover: #155a92; + --primary-foreground: #ffffff; + --secondary: #f1f4f9; + --secondary-foreground: #262262; + --muted: #e6e7e8; + --muted-foreground: #58595b; + --accent: #27aae1; + --accent-foreground: #ffffff; + --success: #00a79d; + --warning: #d97706; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #dfe3ea; + --border-hover: #c8d0e0; + --input: transparent; + --input-background: #f9fafb; + --switch-background: #c8d0e0; + --ring: #1c75bc; + + --chart-1: #1c75bc; + --chart-2: #00a79d; + --chart-3: #d97706; + --chart-4: #7c3aed; + --chart-5: #dc2626; + + /* ── SIDEBAR (light surface — Seahaven DS) ──────────────────────── */ + --sidebar: #f6f8fb; + --sidebar-bg: #f6f8fb; + --sidebar-foreground: #262262; + --sidebar-primary: #1c75bc; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #e6f4fb; + --sidebar-accent-foreground: #155a92; + --sidebar-border: #dfe3ea; + --sidebar-ring: #1c75bc; + + /* ── SEAHAVEN SEMANTIC ALIASES (consumed across the app) ────────── */ + --color-primary: var(--primary); + --color-primary-hover: var(--primary-hover); + --color-primary-foreground: #ffffff; + --color-primary-tint: #e6f4fb; + --color-accent: var(--accent); + + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--foreground); + --color-ring: var(--ring); + + --color-bg-page: #f9fafb; + --color-bg-surface: var(--card); + --color-bg-muted: #f1f4f9; + --color-border: var(--border); + --color-border-hover: #c8d0e0; + --color-text-primary: var(--foreground); + --color-text-secondary: #58595b; + --color-text-muted: #6b7a96; + --color-text-inverse: #ffffff; + + /* ── SIDEBAR ALIASES (light) ────────────────────────────────────── */ + --color-sidebar-bg: var(--sidebar-bg); + --color-sidebar-text: #262262; + --color-sidebar-text-hover: #155a92; + --color-sidebar-active-bg: var(--sidebar-accent); + --color-sidebar-active-text: var(--sidebar-accent-foreground); + --color-sidebar-hover-bg: #eef2f7; + --color-sidebar-label: #6b7a96; + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-badge-bg: #e6f4fb; + --color-sidebar-badge-text: #155a92; + --color-sidebar-item-active-bg: var(--card); + --color-sidebar-item-active-shadow: 0 1px 3px rgba(38, 34, 98, 0.08); + + /* ── SEMANTIC (universal meaning) ───────────────────────────────── */ + --color-destructive: var(--destructive); + --color-destructive-fg: #ffffff; + --color-warning: var(--warning); + --color-success: var(--success); + + /* ── STATUS TOKENS (immutable — mirror SHOC; consumed by MuiAlert + mapping and proposal status badges) ──────────────────────────── */ + --status-incomplete-bg: #f3f4f6; + --status-incomplete-text: #374151; + --status-pending-bg: #fff7ed; + --status-pending-text: #92400e; + --status-scheduled-bg: #eff6ff; + --status-scheduled-text: #1e40af; + --status-inprogress-bg: #eff6ff; + --status-inprogress-text: #1d4ed8; + --status-completed-bg: #f0fdf4; + --status-completed-text: #166534; + --status-canceled-bg: #f9fafb; + --status-canceled-text: #6b7280; + --status-pastdue-bg: #fff1f2; + --status-pastdue-text: #9f1239; + + /* ── COMPLETION DOCUMENT (immutable — mirror SHOC) ──────────────── */ + --doc-yes-bg: #f0fdf4; + --doc-yes-text: #166534; + --doc-no-bg: #fff1f2; + --doc-no-text: #be123c; + --doc-nn-bg: #f3f4f6; + --doc-nn-text: #6b7280; + + /* ── SLA (immutable — mirror SHOC) ──────────────────────────────── */ + --sla-safe: #16a34a; + --sla-warning: #d97706; + --sla-critical: #dc2626; + + /* ── HEADER (app shell top bar) ─────────────────────────────────── */ + --color-header-bg-start: #1b1f52; + --color-header-bg-mid: #1c4f8f; + --color-header-bg-end: var(--primary); + --color-header-gradient: linear-gradient( + 100deg, + var(--color-header-bg-start) 0%, + var(--color-header-bg-mid) 58%, + var(--color-header-bg-end) 100% + ); + --color-header-shadow: 0 1px 0 rgba(38, 34, 98, 0.18), 0 6px 18px -10px rgba(28, 117, 188, 0.5); + --color-header-surface: rgba(255, 255, 255, 0.08); + --color-header-surface-hover: rgba(255, 255, 255, 0.18); + --color-header-chip-hover: rgba(255, 255, 255, 0.12); + --color-header-icon: #eaf3fb; + --color-header-text: #ffffff; + --color-header-text-muted: #bdd8f0; + --color-header-avatar-ring: rgba(255, 255, 255, 0.25); +} diff --git a/web/src/styles/typography.css b/web/src/styles/typography.css new file mode 100644 index 0000000..c9fb518 --- /dev/null +++ b/web/src/styles/typography.css @@ -0,0 +1,52 @@ +/* Base element typography — mirrors shoc-frontend-new dev src/styles/typography.css. */ +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; + font-family: var(--font-sans); + color: var(--foreground); + background-color: var(--color-bg-page); +} + +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-heading); + color: var(--foreground); + margin: 0; +} + +h1 { font-size: var(--text-2xl); font-weight: 800; } +h2 { font-size: var(--text-xl); font-weight: 700; } +h3 { font-size: var(--text-lg); font-weight: 600; } +h4 { font-size: var(--text-md); font-weight: 600; } +h5 { font-size: var(--text-base); font-weight: 600; } +h6 { font-size: var(--text-base-sm); font-weight: 600; } + +.text-caption { + font-family: var(--font-sans); + font-size: var(--text-sm); + line-height: var(--leading-normal); + color: var(--muted-foreground); +} + +.text-label { + font-family: var(--font-sans); + font-size: var(--text-xs); + font-weight: 600; + line-height: 1.5; + letter-spacing: var(--tracking-wider); + text-transform: uppercase; + color: var(--color-sidebar-label); +} + +.text-nav-group { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted-foreground); +} diff --git a/web/src/theme.ts b/web/src/theme.ts index 353e284..abba78e 100644 --- a/web/src/theme.ts +++ b/web/src/theme.ts @@ -1,353 +1,4 @@ -import { createTheme } from '@mui/material/styles'; - -// Sea Haven Ops design system — flat, dense, border-driven enterprise UI. -// Neutral navy/slate/gray structure; action-blue #2563EB is the only brand accent. -// Tokens mirror the Sea Haven Ops design bundle (tokens/colors.css, spacing.css, typography.css). -export const theme = createTheme({ - palette: { - primary: { - main: '#2563EB', // action blue — primary buttons, links, selected - light: '#3B82F6', - dark: '#1D4ED8', // contained-button hover/pressed - contrastText: '#FFFFFF', - }, - secondary: { - main: '#4B5563', // slate-600 - light: '#6B7280', - dark: '#374151', - contrastText: '#FFFFFF', - }, - success: { - main: '#16A34A', - light: '#DCFCE7', - dark: '#166534', - contrastText: '#FFFFFF', - }, - warning: { - main: '#D97706', // orange — Urgent - light: '#FFEDD5', - dark: '#9A3412', - contrastText: '#FFFFFF', - }, - error: { - main: '#DC2626', // red — Emergency / overdue / destructive - light: '#FEE2E2', - dark: '#991B1B', - contrastText: '#FFFFFF', - }, - info: { - main: '#2563EB', - light: '#DBEAFE', - dark: '#1E40AF', - contrastText: '#FFFFFF', - }, - background: { - default: '#F3F4F6', // gray-100 page background - paper: '#FFFFFF', - }, - text: { - primary: '#111827', // navy-900 - secondary: '#4B5563', // slate-600 - disabled: '#9CA3AF', // gray-400 - }, - divider: '#E5E7EB', // gray-200 - }, - - typography: { - fontFamily: '"Inter", "Segoe UI", Roboto, Helvetica, Arial, sans-serif', - fontSize: 14, - h4: { - fontWeight: 700, - fontSize: '24px', - lineHeight: 1.25, - letterSpacing: '-0.02em', - }, - h5: { - fontWeight: 600, - fontSize: '18px', - lineHeight: 1.3, - }, - h6: { - fontWeight: 600, - fontSize: '16px', - lineHeight: 1.4, - }, - subtitle1: { - fontWeight: 600, - fontSize: '14px', - lineHeight: 1.4, - }, - body1: { - fontSize: '14px', - lineHeight: 1.5, - }, - body2: { - fontSize: '13px', - lineHeight: 1.45, - }, - caption: { - fontSize: '12px', - lineHeight: 1.35, - color: '#6B7280', - }, - button: { - fontSize: '14px', - fontWeight: 600, - textTransform: 'none' as const, - }, - overline: { - fontSize: '11px', - lineHeight: '16px', - fontWeight: 700, - letterSpacing: '0.08em', - textTransform: 'uppercase' as const, - color: '#6B7280', - }, - }, - - shape: { - borderRadius: 6, - }, - - components: { - MuiCssBaseline: { - styleOverrides: { - body: { - fontFeatureSettings: '"tnum"', - backgroundColor: '#F3F4F6', - }, - }, - }, - - MuiAppBar: { - defaultProps: { - elevation: 0, - }, - styleOverrides: { - root: { - backgroundColor: '#111827', // navy-900 - boxShadow: 'none', - borderRadius: 0, - borderBottom: '1px solid rgba(255,255,255,0.08)', - }, - }, - }, - - MuiDrawer: { - styleOverrides: { - paper: { - backgroundColor: '#FFFFFF', - borderRight: '1px solid #E5E7EB', // border-subtle - width: 216, - }, - }, - }, - - MuiCard: { - defaultProps: { - elevation: 0, - }, - styleOverrides: { - // Cards are border-driven — no shadow (overlays only). - root: { - border: '1px solid #E5E7EB', - boxShadow: 'none', - borderRadius: 8, - }, - }, - }, - - MuiPaper: { - defaultProps: { - elevation: 0, - }, - styleOverrides: { - root: { - backgroundImage: 'none', - borderRadius: 6, - }, - }, - }, - - MuiButton: { - styleOverrides: { - root: { - borderRadius: 6, - boxShadow: 'none', - textTransform: 'none' as const, - fontWeight: 600, - height: 36, - paddingLeft: 14, - paddingRight: 14, - }, - contained: { - boxShadow: 'none', - '&:hover': { - boxShadow: 'none', - }, - }, - outlined: { - borderColor: '#D1D5DB', // border-default - color: '#111827', - '&:hover': { - borderColor: '#9CA3AF', // border-strong - backgroundColor: '#F9FAFB', // gray-50 - }, - }, - }, - }, - - MuiTextField: { - defaultProps: { - size: 'small' as const, - }, - styleOverrides: { - root: { - '& .MuiOutlinedInput-root': { - borderRadius: 6, - }, - }, - }, - }, - - MuiOutlinedInput: { - styleOverrides: { - root: { - borderRadius: 6, - minHeight: 38, - '& .MuiOutlinedInput-notchedOutline': { - borderColor: '#D1D5DB', - }, - '&:hover .MuiOutlinedInput-notchedOutline': { - borderColor: '#9CA3AF', - }, - '&.Mui-focused .MuiOutlinedInput-notchedOutline': { - borderColor: '#2563EB', - borderWidth: 2, - }, - }, - input: { - fontSize: 14, - padding: '9px 12px', - }, - }, - }, - - MuiInputLabel: { - styleOverrides: { - root: { - fontSize: 13, - color: '#6B7280', - '&.Mui-focused': { - color: '#2563EB', - }, - }, - }, - }, - - MuiTableHead: { - styleOverrides: { - root: { - '& .MuiTableCell-head': { - color: '#374151', // slate-700 - fontWeight: 700, - fontSize: 13, - paddingTop: 12, - paddingBottom: 12, - borderBottom: '1px solid #E5E7EB', - backgroundColor: '#F9FAFB', // gray-50 - whiteSpace: 'nowrap', - }, - }, - }, - }, - - MuiTableCell: { - styleOverrides: { - root: { - color: '#111827', - fontSize: 14, - padding: '12px 14px', - borderBottom: '1px solid #E5E7EB', - }, - }, - }, - - MuiTableRow: { - styleOverrides: { - root: { - '&:hover': { - backgroundColor: '#F9FAFB', - }, - }, - }, - }, - - MuiChip: { - styleOverrides: { - root: { - height: 24, - borderRadius: 6, - fontSize: 12, - fontWeight: 700, - }, - sizeSmall: { - height: 22, - fontSize: 12, - }, - label: { - paddingLeft: 8, - paddingRight: 8, - }, - icon: { - fontSize: 14, - marginLeft: 6, - }, - }, - }, - - MuiListItemButton: { - styleOverrides: { - root: { - borderRadius: 6, - marginLeft: 8, - marginRight: 8, - marginBottom: 4, - color: '#4B5563', - '&.Mui-selected': { - backgroundColor: '#EFF6FF', // blue-50 - borderLeft: '3px solid #2563EB', - color: '#111827', - '& .MuiListItemIcon-root': { - color: '#2563EB', - }, - '&:hover': { - backgroundColor: '#DBEAFE', // blue-100 - }, - }, - '&:hover': { - backgroundColor: '#F3F4F6', // gray-100 - }, - }, - }, - }, - - MuiListItemIcon: { - styleOverrides: { - root: { - color: '#9CA3AF', // gray-400 — inactive nav icon - minWidth: 36, - }, - }, - }, - - MuiTooltip: { - styleOverrides: { - tooltip: { - fontSize: 12, - backgroundColor: '#1F2937', // navy-800 - }, - }, - }, - }, -}); +// Sea Haven design system (ADR 0003) — SHOC dev token port. +// Tokens live in src/styles/theme.css (single source of truth); +// src/lib/theme/mui-theme.ts is the getCssVar → createTheme adapter. +export { muiTheme as theme } from './lib/theme/mui-theme'; From 8dc8f7814befaaea64bca8447bf0a2cd93a5b1d8 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 13 Jul 2026 17:19:42 -0400 Subject: [PATCH 2/3] feat(contracts): adopt shared api-contracts in web, add zod schemas and ProblemDetails codes Closes WEB-M5 (web hand-duplicated wire types, standing drift risk): - shared/api-contracts: rewritten as the authoritative superset of the .NET DTOs (ProposalListItem/ProposalDetail with poNumber and submittedByName, line item requests, customers, pricing library, dashboard, audit, sites, auth, presigned upload, ApiProblem); stale Proposal/UpdateLineItemsRequest shapes removed - shared/api-contracts/src/schemas.ts: zod runtime schemas coupled to every wire type via `satisfies z.ZodType` (schema/type drift is now a compile error); separate entrypoint so type-only consumers (mobile) never pull zod - web: imports @proposal-system/api-contracts (file: dep + tsconfig paths + vite preserveSymlinks); all 7 lib/api modules re-export shared types so page imports stay stable; enum unions tightened (PricingLibraryPage form state now ServiceCategory-typed) - fix(web): customer create/update sent a singular `address` field the API silently dropped (contract is addresses: string[], CustomerDtos.cs) - addresses now round-trip, extra addresses preserved on edit - api: ProblemDetails responses carry a machine-readable top-level `code` (SHOC error-code vocabulary): ValidationFailed, InvalidStateTransition, NotFound, Unauthorized, InternalError; new BusinessRuleException(code, message) maps to 422 with its code; GlobalExceptionHandlerTests cover the full mapping (wire contract) Cross-checked .NET DTOs vs TS types vs zod schemas with the orchestrator scanner (Gemini): core domains consistent; internal-only DTOs (FileDtos vendor/lambda surface, SimilarProposalDtos, UserDtos admin surface) intentionally uncovered. Verify: dotnet 166/166, web tsc + vitest 26/26 + build, mobile tsc, shared tsc all green. --- .../Middleware/GlobalExceptionHandler.cs | 59 ++-- .../Common/BusinessRuleException.cs | 17 ++ .../Middleware/GlobalExceptionHandlerTests.cs | 96 ++++++ shared/api-contracts/package-lock.json | 12 + shared/api-contracts/package.json | 3 + shared/api-contracts/src/index.ts | 209 +++++++++++-- shared/api-contracts/src/schemas.ts | 276 ++++++++++++++++++ web/package-lock.json | 27 +- web/package.json | 4 +- web/src/lib/api/admin.ts | 29 +- web/src/lib/api/auth.ts | 17 +- web/src/lib/api/customers.ts | 49 ++-- web/src/lib/api/lineItems.ts | 49 +--- web/src/lib/api/pricingLibrary.ts | 56 +--- web/src/lib/api/proposals.ts | 113 ++----- web/src/lib/api/sites.ts | 11 +- .../customers/CustomerManagementPage.tsx | 5 +- .../pricing-library/PricingLibraryPage.tsx | 4 +- web/tsconfig.json | 8 +- web/vite.config.ts | 6 + 20 files changed, 743 insertions(+), 307 deletions(-) create mode 100644 api/src/ProposalSystem.Application/Common/BusinessRuleException.cs create mode 100644 api/tests/ProposalSystem.Tests/Middleware/GlobalExceptionHandlerTests.cs create mode 100644 shared/api-contracts/src/schemas.ts diff --git a/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs b/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs index 08ee88b..af6206f 100644 --- a/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs +++ b/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs @@ -2,6 +2,7 @@ using System.Net; using System.Text.Json; using FluentValidation; using Microsoft.AspNetCore.Mvc; +using ProposalSystem.Application.Common; namespace ProposalSystem.Api.Middleware; @@ -26,54 +27,52 @@ public class GlobalExceptionHandler : IMiddleware } } + private static ProblemDetails MakeProblem(int status, string title, string detail, string code) + { + var problem = new ProblemDetails + { + Status = status, + Title = title, + Detail = detail, + }; + problem.Extensions["code"] = code; // serialized top-level via [JsonExtensionData] + return problem; + } + private async Task HandleExceptionAsync(HttpContext context, Exception exception) { + // Every response carries a machine-readable "code" extension (SHOC + // error-code vocabulary convention) so clients branch on codes, not + // on human-readable text. var (statusCode, problemDetails) = exception switch { ValidationException validationEx => ( HttpStatusCode.BadRequest, - new ProblemDetails - { - Status = 400, - Title = "Validation Error", - Detail = string.Join("; ", validationEx.Errors.Select(e => e.ErrorMessage)), - } + MakeProblem(400, "Validation Error", + string.Join("; ", validationEx.Errors.Select(e => e.ErrorMessage)), + "ValidationFailed") + ), + BusinessRuleException businessEx => ( + HttpStatusCode.UnprocessableEntity, + MakeProblem(422, "Business Rule Violation", businessEx.Message, businessEx.Code) ), KeyNotFoundException => ( HttpStatusCode.NotFound, - new ProblemDetails - { - Status = 404, - Title = "Not Found", - Detail = "The requested resource was not found", - } + MakeProblem(404, "Not Found", "The requested resource was not found", "NotFound") ), UnauthorizedAccessException => ( HttpStatusCode.Unauthorized, - new ProblemDetails - { - Status = 401, - Title = "Unauthorized", - Detail = "Authentication required", - } + MakeProblem(401, "Unauthorized", "Authentication required", "Unauthorized") ), InvalidOperationException => ( HttpStatusCode.BadRequest, - new ProblemDetails - { - Status = 400, - Title = "Invalid Operation", - Detail = "The requested operation is not valid for the current state", - } + MakeProblem(400, "Invalid Operation", + "The requested operation is not valid for the current state", + "InvalidStateTransition") ), _ => ( HttpStatusCode.InternalServerError, - new ProblemDetails - { - Status = 500, - Title = "Internal Server Error", - Detail = "An unexpected error occurred", - } + MakeProblem(500, "Internal Server Error", "An unexpected error occurred", "InternalError") ), }; diff --git a/api/src/ProposalSystem.Application/Common/BusinessRuleException.cs b/api/src/ProposalSystem.Application/Common/BusinessRuleException.cs new file mode 100644 index 0000000..af1853a --- /dev/null +++ b/api/src/ProposalSystem.Application/Common/BusinessRuleException.cs @@ -0,0 +1,17 @@ +namespace ProposalSystem.Application.Common; + +/// +/// Business-rule violation carrying a machine-readable code, surfaced as +/// 422 ProblemDetails with a top-level "code" extension (SHOC error-code +/// vocabulary convention — e.g. "CancelNotAllowed"). Clients branch on the +/// code, never on the human-readable message. +/// +public class BusinessRuleException : Exception +{ + public string Code { get; } + + public BusinessRuleException(string code, string message) : base(message) + { + Code = code; + } +} diff --git a/api/tests/ProposalSystem.Tests/Middleware/GlobalExceptionHandlerTests.cs b/api/tests/ProposalSystem.Tests/Middleware/GlobalExceptionHandlerTests.cs new file mode 100644 index 0000000..78c5c04 --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Middleware/GlobalExceptionHandlerTests.cs @@ -0,0 +1,96 @@ +using System.Text.Json; +using FluentAssertions; +using FluentValidation; +using FluentValidation.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using NSubstitute; +using ProposalSystem.Api.Middleware; +using ProposalSystem.Application.Common; +using Xunit; + +namespace ProposalSystem.Tests.Middleware; + +/// +/// GlobalExceptionHandler tests — verifies the RFC 7807 ProblemDetails +/// mapping and the machine-readable "code" extension (SHOC error-code +/// vocabulary convention). Clients branch on code, so the code values are +/// wire contract: changing one is a breaking API change. +/// +public class GlobalExceptionHandlerTests +{ + private static async Task<(int Status, JsonElement Body)> InvokeWith(Exception exception) + { + var logger = Substitute.For>(); + var handler = new GlobalExceptionHandler(logger); + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + + await handler.InvokeAsync(context, _ => throw exception); + + context.Response.Body.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(context.Response.Body); + var body = JsonDocument.Parse(await reader.ReadToEndAsync()).RootElement.Clone(); + return (context.Response.StatusCode, body); + } + + [Fact(DisplayName = "BusinessRuleException maps to 422 with its business code")] + public async Task BusinessRuleException_Maps422WithCode() + { + var (status, body) = await InvokeWith( + new BusinessRuleException("CancelNotAllowed", "Sent proposals cannot be canceled")); + + status.Should().Be(422); + body.GetProperty("code").GetString().Should().Be("CancelNotAllowed"); + body.GetProperty("title").GetString().Should().Be("Business Rule Violation"); + body.GetProperty("detail").GetString().Should().Be("Sent proposals cannot be canceled"); + } + + [Fact(DisplayName = "InvalidOperationException maps to 400 InvalidStateTransition")] + public async Task InvalidOperationException_Maps400InvalidStateTransition() + { + var (status, body) = await InvokeWith(new InvalidOperationException("bad transition")); + + status.Should().Be(400); + body.GetProperty("code").GetString().Should().Be("InvalidStateTransition"); + // Detail must stay generic — no internal exception text on the wire. + body.GetProperty("detail").GetString().Should().NotContain("bad transition"); + } + + [Fact(DisplayName = "ValidationException maps to 400 ValidationFailed")] + public async Task ValidationException_Maps400ValidationFailed() + { + var failures = new[] { new ValidationFailure("Name", "Name is required") }; + var (status, body) = await InvokeWith(new ValidationException(failures)); + + status.Should().Be(400); + body.GetProperty("code").GetString().Should().Be("ValidationFailed"); + body.GetProperty("detail").GetString().Should().Contain("Name is required"); + } + + [Theory(DisplayName = "Standard exceptions map to their status and code")] + [InlineData(typeof(KeyNotFoundException), 404, "NotFound")] + [InlineData(typeof(UnauthorizedAccessException), 401, "Unauthorized")] + [InlineData(typeof(ApplicationException), 500, "InternalError")] + public async Task StandardExceptions_MapToStatusAndCode(Type exceptionType, int expectedStatus, string expectedCode) + { + var exception = (Exception)Activator.CreateInstance(exceptionType)!; + var (status, body) = await InvokeWith(exception); + + status.Should().Be(expectedStatus); + body.GetProperty("code").GetString().Should().Be(expectedCode); + } + + [Fact(DisplayName = "Responses use application/problem+json")] + public async Task Responses_UseProblemJsonContentType() + { + var logger = Substitute.For>(); + var handler = new GlobalExceptionHandler(logger); + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + + await handler.InvokeAsync(context, _ => throw new KeyNotFoundException()); + + context.Response.ContentType.Should().Be("application/problem+json"); + } +} diff --git a/shared/api-contracts/package-lock.json b/shared/api-contracts/package-lock.json index 21217e1..3468ed6 100644 --- a/shared/api-contracts/package-lock.json +++ b/shared/api-contracts/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "@proposal-system/api-contracts", "version": "0.1.0", + "dependencies": { + "zod": "^4.4.3" + }, "devDependencies": { "typescript": "~7.0.2" } @@ -385,6 +388,15 @@ "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/shared/api-contracts/package.json b/shared/api-contracts/package.json index 85c2a65..59f22a7 100644 --- a/shared/api-contracts/package.json +++ b/shared/api-contracts/package.json @@ -9,5 +9,8 @@ }, "devDependencies": { "typescript": "~7.0.2" + }, + "dependencies": { + "zod": "^4.4.3" } } diff --git a/shared/api-contracts/src/index.ts b/shared/api-contracts/src/index.ts index 6a14010..f189eb2 100644 --- a/shared/api-contracts/src/index.ts +++ b/shared/api-contracts/src/index.ts @@ -1,15 +1,46 @@ +// @proposal-system/api-contracts — single source of truth for API wire types. +// Fix: WEB-M5 — web and mobile both import these; shapes mirror the .NET DTOs +// in api/src/ProposalSystem.Application/DTOs/ (JsonStringEnumConverter → string +// enums, Guid → string, DateTime → ISO string, decimal → number). +// Runtime validation schemas live in ./schemas (separate entrypoint so +// consumers that only need types never pull zod). + +// ── Enums (string over the wire) ────────────────────────────────────────── export type ProposalStatus = 'Draft' | 'InReview' | 'Approved' | 'Sent' | 'Revised'; -// Fix: WEB-M4 — align with API enum (ProposalSystem.Domain.Entities.ServiceCategory includes 'Other') export type ServiceCategory = 'HVAC' | 'Plumbing' | 'Electrical' | 'General' | 'Renovation' | 'Other'; export type Priority = 'Standard' | 'Urgent' | 'Emergency'; export type LineItemSource = 'AI' | 'Vendor' | 'Manual' | 'Historical'; export type PricingMode = 'UnitPrice' | 'TotalPrice' | 'Both'; export type UserRole = 'Dispatcher' | 'Admin' | 'SysAdmin'; -export interface Proposal { +// ── Shared envelopes ────────────────────────────────────────────────────── +export interface PagedResponse { + items: T[]; + totalCount: number; + page: number; + pageSize: number; +} + +// ── Proposals (ProposalDtos.cs) ─────────────────────────────────────────── +export interface ProposalListItem { + id: string; + proposalNumber: string; + customerName: string; + workOrderNumber: string; + serviceCategory: ServiceCategory; + priority: Priority; + status: ProposalStatus; + totalBidAmount: number; + submittedAt: string; + submittedByName: string | null; + assignedAdminName: string | null; +} + +export interface ProposalDetail { id: string; proposalNumber: string; workOrderNumber: string; + poNumber: string | null; customerName: string; customerAddress: string; scopeOfWork: string; @@ -21,6 +52,7 @@ export interface Proposal { vendorTotalCost: number | null; notes: string; submittedById: string; + submittedByName: string | null; submittedAt: string; assignedAdminId: string | null; approvedById: string | null; @@ -32,6 +64,45 @@ export interface Proposal { updatedAt: string; } +export interface CreateProposalRequest { + workOrderNumber: string; + poNumber?: string; + customerName: string; + customerAddress: string; + scopeOfWork: string; + serviceCategory: ServiceCategory; + priority: Priority; + notes?: string; +} + +export interface UpdateProposalRequest { + refinedScope?: string; + notes?: string; + poNumber?: string; + workOrderNumber?: string; + assignedAdminId?: string; +} + +export interface ProposalFilters { + search?: string; + page?: number; + pageSize?: number; + status?: string; + serviceCategory?: string; + priority?: string; + fromDate?: string; + toDate?: string; + mine?: boolean; +} + +export interface ProposalStats { + totalCount: number; + inReviewCount: number; + approvedCount: number; + sentCount: number; +} + +// ── Line items (LineItemDtos.cs) ────────────────────────────────────────── export interface LineItem { id: string; proposalId: string; @@ -47,20 +118,34 @@ export interface LineItem { updatedAt: string; } -export interface CreateProposalRequest { - workOrderNumber: string; - customerName: string; - customerAddress: string; - scopeOfWork: string; - serviceCategory: ServiceCategory; - priority: Priority; - notes?: string; +export interface CreateLineItemRequest { + description: string; + quantity: number; + unit: string; + unitPrice: number | null; + totalPrice: number; + pricingMode: PricingMode; + sortOrder: number; + source: LineItemSource; } -export interface UpdateLineItemsRequest { - lineItems: Omit[]; +export interface UpdateLineItemEntry { + id: string | null; + description: string; + quantity: number; + unit: string; + unitPrice: number | null; + totalPrice: number; + pricingMode: PricingMode; + sortOrder: number; + source: LineItemSource; } +export interface BulkUpdateLineItemsRequest { + lineItems: UpdateLineItemEntry[]; +} + +// ── Customers (CustomerDtos.cs) ─────────────────────────────────────────── export interface Customer { id: string; name: string; @@ -69,6 +154,12 @@ export interface Customer { createdAt: string; } +export interface CustomerListParams { + page?: number; + pageSize?: number; + search?: string; +} + export interface CreateCustomerRequest { name: string; addresses?: string[]; @@ -81,14 +172,7 @@ export interface UpdateCustomerRequest { contactEmail?: string | null; } -export interface PagedResponse { - items: T[]; - totalCount: number; - page: number; - pageSize: number; -} - -// PR5: Pricing Library types +// ── Pricing library (PricingLibraryDtos.cs) ─────────────────────────────── export interface PricingLibraryItem { id: string; title: string; @@ -102,12 +186,17 @@ export interface PricingLibraryItem { updatedAt: string; } +export interface PricingLibraryListParams { + page?: number; + pageSize?: number; +} + export interface CreatePricingLibraryItemRequest { title: string; description?: string; serviceCategory: ServiceCategory; unit?: string; - unitPrice?: number; + unitPrice?: number | null; keywords?: string; source?: string; } @@ -117,6 +206,82 @@ export interface UpdatePricingLibraryItemRequest { description?: string; serviceCategory?: ServiceCategory; unit?: string; - unitPrice?: number; + unitPrice?: number | null; keywords?: string; } + +// ── Admin (DashboardDtos.cs, AuditDtos.cs) ──────────────────────────────── +export interface DashboardStats { + pendingCount: number; + approvedThisWeek: number; + avgTurnaroundHours: number; + totalProposals: number; +} + +export interface AuditEntry { + id: string; + proposalId: string | null; + userId: string; + userName: string | null; + action: string; + details: string | null; + timestamp: string; + ipAddress: string | null; +} + +// ── Sites (SiteDtos.cs) ─────────────────────────────────────────────────── +export interface Site { + siteCode: string; + fullAddress: string | null; + address: string | null; + city: string | null; + state: string | null; + zip: string | null; +} + +// ── Auth / users (UserDtos.cs, AuthController) ──────────────────────────── +export interface AuthUser { + id: string; + email: string; + displayName: string; + role: UserRole; + token: string; +} + +export interface UserProfile { + id: string; + email: string; + displayName: string; + role: UserRole; +} + +// ── Files / PDFs (FileDtos.cs) ──────────────────────────────────────────── +export interface PdfVersion { + revision: number; + generatedAt: string; +} + +export interface PresignedUpload { + uploadUrl: string; + s3Key: string; + expiresAt: string; + vendorProposalId: string; +} + +// ── Errors (GlobalExceptionHandler.cs) ──────────────────────────────────── +// RFC 7807 ProblemDetails + machine-readable business code (SHOC error-code +// vocabulary convention). Branch on `code`, never on title/detail text. +export type ApiProblemCode = + | 'ValidationFailed' + | 'InvalidStateTransition' + | 'NotFound' + | 'Unauthorized' + | 'InternalError' + | (string & {}); // business codes are open-ended (e.g. future CancelNotAllowed) + +export interface ApiProblem { + status: number; + title: string; + detail: string; + code: ApiProblemCode; +} diff --git a/shared/api-contracts/src/schemas.ts b/shared/api-contracts/src/schemas.ts new file mode 100644 index 0000000..6929c86 --- /dev/null +++ b/shared/api-contracts/src/schemas.ts @@ -0,0 +1,276 @@ +// Runtime validation schemas coupled to the wire types in ./index. +// Separate entrypoint by design: consumers that only need types (mobile) +// never pull zod. Each schema is compile-time-checked against its type via +// `satisfies z.ZodType` — if a DTO and its schema drift, tsc fails here. +import { z } from 'zod'; +import type { + ProposalStatus, + ServiceCategory, + Priority, + LineItemSource, + PricingMode, + UserRole, + ProposalListItem, + ProposalDetail, + CreateProposalRequest, + UpdateProposalRequest, + LineItem, + CreateLineItemRequest, + UpdateLineItemEntry, + BulkUpdateLineItemsRequest, + Customer, + CreateCustomerRequest, + UpdateCustomerRequest, + PricingLibraryItem, + CreatePricingLibraryItemRequest, + UpdatePricingLibraryItemRequest, + DashboardStats, + AuditEntry, + Site, + AuthUser, + UserProfile, + ProposalStats, + PdfVersion, + PresignedUpload, + ApiProblem, +} from './index'; + +// ── Enums ───────────────────────────────────────────────────────────────── +export const proposalStatusSchema = z.enum(['Draft', 'InReview', 'Approved', 'Sent', 'Revised']) satisfies z.ZodType; +export const serviceCategorySchema = z.enum(['HVAC', 'Plumbing', 'Electrical', 'General', 'Renovation', 'Other']) satisfies z.ZodType; +export const prioritySchema = z.enum(['Standard', 'Urgent', 'Emergency']) satisfies z.ZodType; +export const lineItemSourceSchema = z.enum(['AI', 'Vendor', 'Manual', 'Historical']) satisfies z.ZodType; +export const pricingModeSchema = z.enum(['UnitPrice', 'TotalPrice', 'Both']) satisfies z.ZodType; +export const userRoleSchema = z.enum(['Dispatcher', 'Admin', 'SysAdmin']) satisfies z.ZodType; + +// ── Proposals ───────────────────────────────────────────────────────────── +export const proposalListItemSchema = z.object({ + id: z.string(), + proposalNumber: z.string(), + customerName: z.string(), + workOrderNumber: z.string(), + serviceCategory: serviceCategorySchema, + priority: prioritySchema, + status: proposalStatusSchema, + totalBidAmount: z.number(), + submittedAt: z.string(), + submittedByName: z.string().nullable(), + assignedAdminName: z.string().nullable(), +}) satisfies z.ZodType; + +export const proposalDetailSchema = z.object({ + id: z.string(), + proposalNumber: z.string(), + workOrderNumber: z.string(), + poNumber: z.string().nullable(), + customerName: z.string(), + customerAddress: z.string(), + scopeOfWork: z.string(), + refinedScope: z.string().nullable(), + serviceCategory: serviceCategorySchema, + priority: prioritySchema, + status: proposalStatusSchema, + totalBidAmount: z.number(), + vendorTotalCost: z.number().nullable(), + notes: z.string(), + submittedById: z.string(), + submittedByName: z.string().nullable(), + submittedAt: z.string(), + assignedAdminId: z.string().nullable(), + approvedById: z.string().nullable(), + approvedAt: z.string().nullable(), + sentAt: z.string().nullable(), + currentRevision: z.number(), + parentProposalId: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}) satisfies z.ZodType; + +export const createProposalRequestSchema = z.object({ + workOrderNumber: z.string().min(1), + poNumber: z.string().optional(), + customerName: z.string().min(1), + customerAddress: z.string().min(1), + scopeOfWork: z.string().min(1), + serviceCategory: serviceCategorySchema, + priority: prioritySchema, + notes: z.string().optional(), +}) satisfies z.ZodType; + +export const updateProposalRequestSchema = z.object({ + refinedScope: z.string().optional(), + notes: z.string().optional(), + poNumber: z.string().optional(), + workOrderNumber: z.string().optional(), + assignedAdminId: z.string().optional(), +}) satisfies z.ZodType; + +export const proposalStatsSchema = z.object({ + totalCount: z.number(), + inReviewCount: z.number(), + approvedCount: z.number(), + sentCount: z.number(), +}) satisfies z.ZodType; + +// ── Line items ──────────────────────────────────────────────────────────── +export const lineItemSchema = z.object({ + id: z.string(), + proposalId: z.string(), + description: z.string(), + quantity: z.number(), + unit: z.string(), + unitPrice: z.number().nullable(), + totalPrice: z.number(), + pricingMode: pricingModeSchema, + sortOrder: z.number(), + source: lineItemSourceSchema, + createdAt: z.string(), + updatedAt: z.string(), +}) satisfies z.ZodType; + +export const createLineItemRequestSchema = z.object({ + description: z.string().min(1), + quantity: z.number(), + unit: z.string(), + unitPrice: z.number().nullable(), + totalPrice: z.number(), + pricingMode: pricingModeSchema, + sortOrder: z.number(), + source: lineItemSourceSchema, +}) satisfies z.ZodType; + +export const updateLineItemEntrySchema = z.object({ + id: z.string().nullable(), + description: z.string().min(1), + quantity: z.number(), + unit: z.string(), + unitPrice: z.number().nullable(), + totalPrice: z.number(), + pricingMode: pricingModeSchema, + sortOrder: z.number(), + source: lineItemSourceSchema, +}) satisfies z.ZodType; + +export const bulkUpdateLineItemsRequestSchema = z.object({ + lineItems: z.array(updateLineItemEntrySchema), +}) satisfies z.ZodType; + +// ── Customers ───────────────────────────────────────────────────────────── +export const customerSchema = z.object({ + id: z.string(), + name: z.string(), + addresses: z.array(z.string()), + contactEmail: z.string().nullable(), + createdAt: z.string(), +}) satisfies z.ZodType; + +export const createCustomerRequestSchema = z.object({ + name: z.string().min(1), + addresses: z.array(z.string()).optional(), + contactEmail: z.string().optional(), +}) satisfies z.ZodType; + +export const updateCustomerRequestSchema = z.object({ + name: z.string().optional(), + addresses: z.array(z.string()).optional(), + contactEmail: z.string().nullable().optional(), +}) satisfies z.ZodType; + +// ── Pricing library ─────────────────────────────────────────────────────── +export const pricingLibraryItemSchema = z.object({ + id: z.string(), + title: z.string(), + description: z.string().nullable(), + serviceCategory: serviceCategorySchema, + unit: z.string().nullable(), + unitPrice: z.number().nullable(), + keywords: z.string().nullable(), + source: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}) satisfies z.ZodType; + +export const createPricingLibraryItemRequestSchema = z.object({ + title: z.string().min(1), + description: z.string().optional(), + serviceCategory: serviceCategorySchema, + unit: z.string().optional(), + unitPrice: z.number().nullable().optional(), + keywords: z.string().optional(), + source: z.string().optional(), +}) satisfies z.ZodType; + +export const updatePricingLibraryItemRequestSchema = z.object({ + title: z.string().optional(), + description: z.string().optional(), + serviceCategory: serviceCategorySchema.optional(), + unit: z.string().optional(), + unitPrice: z.number().nullable().optional(), + keywords: z.string().optional(), +}) satisfies z.ZodType; + +// ── Admin ───────────────────────────────────────────────────────────────── +export const dashboardStatsSchema = z.object({ + pendingCount: z.number(), + approvedThisWeek: z.number(), + avgTurnaroundHours: z.number(), + totalProposals: z.number(), +}) satisfies z.ZodType; + +export const auditEntrySchema = z.object({ + id: z.string(), + proposalId: z.string().nullable(), + userId: z.string(), + userName: z.string().nullable(), + action: z.string(), + details: z.string().nullable(), + timestamp: z.string(), + ipAddress: z.string().nullable(), +}) satisfies z.ZodType; + +// ── Sites ───────────────────────────────────────────────────────────────── +export const siteSchema = z.object({ + siteCode: z.string(), + fullAddress: z.string().nullable(), + address: z.string().nullable(), + city: z.string().nullable(), + state: z.string().nullable(), + zip: z.string().nullable(), +}) satisfies z.ZodType; + +// ── Auth / users ────────────────────────────────────────────────────────── +export const authUserSchema = z.object({ + id: z.string(), + email: z.string(), + displayName: z.string(), + role: userRoleSchema, + token: z.string(), +}) satisfies z.ZodType; + +export const userProfileSchema = z.object({ + id: z.string(), + email: z.string(), + displayName: z.string(), + role: userRoleSchema, +}) satisfies z.ZodType; + +// ── Files / PDFs ────────────────────────────────────────────────────────── +export const pdfVersionSchema = z.object({ + revision: z.number(), + generatedAt: z.string(), +}) satisfies z.ZodType; + +export const presignedUploadSchema = z.object({ + uploadUrl: z.string(), + s3Key: z.string(), + expiresAt: z.string(), + vendorProposalId: z.string(), +}) satisfies z.ZodType; + +// ── Errors ──────────────────────────────────────────────────────────────── +export const apiProblemSchema = z.object({ + status: z.number(), + title: z.string(), + detail: z.string(), + code: z.string(), +}) satisfies z.ZodType; diff --git a/web/package-lock.json b/web/package-lock.json index 60df75c..5d716c3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,6 +15,7 @@ "@fontsource/montserrat": "^5.2.8", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.1.1", + "@proposal-system/api-contracts": "file:../shared/api-contracts", "@reduxjs/toolkit": "^2.11.2", "@tanstack/react-query": "^5.101.2", "axios": "^1.18.1", @@ -23,7 +24,8 @@ "react-dom": "^19.2.7", "react-redux": "^9.2.0", "react-router-dom": "^7.18.1", - "react-toastify": "^11.0.5" + "react-toastify": "^11.0.5", + "zod": "^4.4.3" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", @@ -38,6 +40,16 @@ "vitest": "^4.1.10" } }, + "../shared/api-contracts": { + "name": "@proposal-system/api-contracts", + "version": "0.1.0", + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "typescript": "~7.0.2" + } + }, "node_modules/@adobe/css-tools": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", @@ -920,6 +932,10 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@proposal-system/api-contracts": { + "resolved": "../shared/api-contracts", + "link": true + }, "node_modules/@reduxjs/toolkit": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", @@ -4002,6 +4018,15 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/web/package.json b/web/package.json index 42707cc..321cf87 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "@fontsource/montserrat": "^5.2.8", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.1.1", + "@proposal-system/api-contracts": "file:../shared/api-contracts", "@reduxjs/toolkit": "^2.11.2", "@tanstack/react-query": "^5.101.2", "axios": "^1.18.1", @@ -26,7 +27,8 @@ "react-dom": "^19.2.7", "react-redux": "^9.2.0", "react-router-dom": "^7.18.1", - "react-toastify": "^11.0.5" + "react-toastify": "^11.0.5", + "zod": "^4.4.3" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 47df6b8..918524c 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -1,31 +1,8 @@ import apiClient from './client'; -import { type ProposalDetail } from './proposals'; +// Fix: WEB-M5 — types from the shared contracts package. +import type { ProposalDetail, UpdateProposalRequest, DashboardStats, AuditEntry } from '@proposal-system/api-contracts'; -export interface DashboardStats { - pendingCount: number; - approvedThisWeek: number; - avgTurnaroundHours: number; - totalProposals: number; -} - -export interface UpdateProposalRequest { - refinedScope?: string; - notes?: string; - poNumber?: string; - workOrderNumber?: string; - assignedAdminId?: string; -} - -export interface AuditEntry { - id: string; - proposalId: string | null; - userId: string; - userName: string; - action: string; - details: string | null; - timestamp: string; - ipAddress: string | null; -} +export type { DashboardStats, UpdateProposalRequest, AuditEntry } from '@proposal-system/api-contracts'; export const adminApi = { getDashboard: async (): Promise => { diff --git a/web/src/lib/api/auth.ts b/web/src/lib/api/auth.ts index b964dc2..673cc77 100644 --- a/web/src/lib/api/auth.ts +++ b/web/src/lib/api/auth.ts @@ -1,19 +1,8 @@ import apiClient from './client'; +// Fix: WEB-M5 — types from the shared contracts package. +import type { AuthUser, UserProfile } from '@proposal-system/api-contracts'; -export interface AuthUser { - id: string; - email: string; - displayName: string; - role: 'Dispatcher' | 'Admin' | 'SysAdmin'; - token: string; -} - -export interface UserProfile { - id: string; - email: string; - displayName: string; - role: 'Dispatcher' | 'Admin' | 'SysAdmin'; -} +export type { AuthUser, UserProfile } from '@proposal-system/api-contracts'; export const authApi = { exchangeCode: async (code: string, redirectUri: string): Promise => { diff --git a/web/src/lib/api/customers.ts b/web/src/lib/api/customers.ts index ddef320..61ee7ee 100644 --- a/web/src/lib/api/customers.ts +++ b/web/src/lib/api/customers.ts @@ -1,38 +1,23 @@ import apiClient from './client'; +// Fix: WEB-M5 — types from the shared contracts package. Note: the API takes +// addresses: string[] (CustomerDtos.cs); the old local CreateCustomerRequest +// sent a singular `address` field the API silently dropped. +import type { + Customer, + CustomerListParams, + CreateCustomerRequest, + UpdateCustomerRequest, + PagedResponse, +} from '@proposal-system/api-contracts'; -export interface Customer { - id: string; - name: string; - addresses: string[]; - /** Contact email for proposal delivery. Null when not set. */ - contactEmail?: string | null; - createdAt: string; -} +export type { + Customer, + CustomerListParams, + CreateCustomerRequest, + UpdateCustomerRequest, +} from '@proposal-system/api-contracts'; -export interface CustomerListParams { - page?: number; - pageSize?: number; - search?: string; -} - -export interface PagedCustomerResponse { - items: Customer[]; - totalCount: number; - page: number; - pageSize: number; -} - -export interface CreateCustomerRequest { - name: string; - address: string; - contactEmail?: string; -} - -export interface UpdateCustomerRequest { - name: string; - address: string; - contactEmail?: string; -} +export type PagedCustomerResponse = PagedResponse; export const customersApi = { /** GET /api/customers?query= — search autocomplete (unchanged). */ diff --git a/web/src/lib/api/lineItems.ts b/web/src/lib/api/lineItems.ts index a3f3fc4..95f4eb8 100644 --- a/web/src/lib/api/lineItems.ts +++ b/web/src/lib/api/lineItems.ts @@ -1,45 +1,14 @@ import apiClient from './client'; +// Fix: WEB-M5 — types from the shared contracts package. +import type { LineItem, CreateLineItemRequest, UpdateLineItemEntry } from '@proposal-system/api-contracts'; -export type PricingMode = 'UnitPrice' | 'TotalPrice' | 'Both'; -export type LineItemSource = 'AI' | 'Vendor' | 'Manual' | 'Historical'; - -export interface LineItem { - id: string; - proposalId: string; - description: string; - quantity: number; - unit: string; - unitPrice: number | null; - totalPrice: number; - pricingMode: PricingMode; - sortOrder: number; - source: LineItemSource; - createdAt: string; - updatedAt: string; -} - -export interface CreateLineItemRequest { - description: string; - quantity: number; - unit: string; - unitPrice: number | null; - totalPrice: number; - pricingMode: PricingMode; - sortOrder: number; - source: LineItemSource; -} - -export interface UpdateLineItemEntry { - id: string | null; - description: string; - quantity: number; - unit: string; - unitPrice: number | null; - totalPrice: number; - pricingMode: PricingMode; - sortOrder: number; - source: LineItemSource; -} +export type { + PricingMode, + LineItemSource, + LineItem, + CreateLineItemRequest, + UpdateLineItemEntry, +} from '@proposal-system/api-contracts'; export const lineItemsApi = { getAll: async (proposalId: string): Promise => { diff --git a/web/src/lib/api/pricingLibrary.ts b/web/src/lib/api/pricingLibrary.ts index d477779..a0b4bb0 100644 --- a/web/src/lib/api/pricingLibrary.ts +++ b/web/src/lib/api/pricingLibrary.ts @@ -1,47 +1,21 @@ import apiClient from './client'; +// Fix: WEB-M5 — types from the shared contracts package. +import type { + PricingLibraryItem, + PricingLibraryListParams, + CreatePricingLibraryItemRequest, + UpdatePricingLibraryItemRequest, + PagedResponse, +} from '@proposal-system/api-contracts'; -export interface PricingLibraryItem { - id: string; - title: string; - description?: string | null; - serviceCategory: string; - unit?: string | null; - unitPrice?: number | null; - keywords?: string | null; - source: string; - createdAt: string; - updatedAt: string; -} +export type { + PricingLibraryItem, + PricingLibraryListParams, + CreatePricingLibraryItemRequest, + UpdatePricingLibraryItemRequest, +} from '@proposal-system/api-contracts'; -export interface PricingLibraryListParams { - page?: number; - pageSize?: number; -} - -export interface PagedPricingLibraryResponse { - items: PricingLibraryItem[]; - totalCount: number; - page: number; - pageSize: number; -} - -export interface CreatePricingLibraryItemRequest { - title: string; - serviceCategory: string; - description?: string; - unit?: string; - unitPrice?: number | null; - keywords?: string; -} - -export interface UpdatePricingLibraryItemRequest { - title: string; - serviceCategory: string; - description?: string; - unit?: string; - unitPrice?: number | null; - keywords?: string; -} +export type PagedPricingLibraryResponse = PagedResponse; export const pricingLibraryApi = { /** GET /api/pricing-library/list?page=&pageSize= — paginated list (admin/sysadmin). */ diff --git a/web/src/lib/api/proposals.ts b/web/src/lib/api/proposals.ts index cac276c..024d1d8 100644 --- a/web/src/lib/api/proposals.ts +++ b/web/src/lib/api/proposals.ts @@ -1,80 +1,29 @@ import apiClient from './client'; +// Fix: WEB-M5 — types now come from the shared contracts package (single source +// of truth aligned with the .NET DTOs); re-exported so page imports stay stable. +import type { + CreateProposalRequest, + ProposalDetail, + ProposalListItem, + ProposalFilters, + ProposalStats, + PagedResponse, + PdfVersion, + PresignedUpload, +} from '@proposal-system/api-contracts'; -// Fix: WEB-M5 — align with shared contract types (shared/api-contracts/src/index.ts) -export type ServiceCategory = 'HVAC' | 'Plumbing' | 'Electrical' | 'General' | 'Renovation' | 'Other'; -export type Priority = 'Standard' | 'Urgent' | 'Emergency'; - -export interface CreateProposalRequest { - workOrderNumber: string; - poNumber?: string; - customerName: string; - customerAddress: string; - scopeOfWork: string; - serviceCategory: ServiceCategory; - priority: Priority; - notes?: string; -} - -export interface ProposalListItem { - id: string; - proposalNumber: string; - customerName: string; - workOrderNumber: string; - serviceCategory: string; - priority: string; - status: string; - totalBidAmount: number; - submittedAt: string; - submittedByName: string | null; - assignedAdminName: string | null; -} - -export interface ProposalDetail { - id: string; - proposalNumber: string; - workOrderNumber: string; - poNumber: string | null; - customerName: string; - customerAddress: string; - scopeOfWork: string; - refinedScope: string | null; - serviceCategory: string; - priority: string; - status: string; - totalBidAmount: number; - vendorTotalCost: number | null; - notes: string; - submittedById: string; - submittedByName: string | null; - submittedAt: string; - assignedAdminId: string | null; - approvedById: string | null; - approvedAt: string | null; - sentAt: string | null; - currentRevision: number; - parentProposalId: string | null; - createdAt: string; - updatedAt: string; -} - -export interface PagedResponse { - items: T[]; - totalCount: number; - page: number; - pageSize: number; -} - -export interface ProposalFilters { - search?: string; - page?: number; - pageSize?: number; - status?: string; - serviceCategory?: string; - priority?: string; - fromDate?: string; - toDate?: string; - mine?: boolean; -} +export type { + ServiceCategory, + Priority, + CreateProposalRequest, + ProposalDetail, + ProposalListItem, + ProposalFilters, + ProposalStats, + PagedResponse, + PdfVersion, + PresignedUpload, +} from '@proposal-system/api-contracts'; export const proposalsApi = { create: async (data: CreateProposalRequest): Promise => { @@ -103,7 +52,7 @@ export const proposalsApi = { return res.data; }, - uploadAttachment: async (proposalId: string, fileName: string, vendorName?: string): Promise<{ uploadUrl: string; s3Key: string; vendorProposalId: string }> => { + uploadAttachment: async (proposalId: string, fileName: string, vendorName?: string): Promise => { const params = new URLSearchParams({ fileName }); if (vendorName) params.append('vendorName', vendorName); const res = await apiClient.post(`/proposals/${proposalId}/attachments?${params.toString()}`); @@ -146,15 +95,3 @@ export const proposalsApi = { return { downloadUrl }; }, }; - -export interface ProposalStats { - totalCount: number; - inReviewCount: number; - approvedCount: number; - sentCount: number; -} - -export interface PdfVersion { - revision: number; - generatedAt: string; -} diff --git a/web/src/lib/api/sites.ts b/web/src/lib/api/sites.ts index e65bb19..eb0b8a8 100644 --- a/web/src/lib/api/sites.ts +++ b/web/src/lib/api/sites.ts @@ -1,13 +1,8 @@ import apiClient from './client'; +// Fix: WEB-M5 — types from the shared contracts package. +import type { Site } from '@proposal-system/api-contracts'; -export interface Site { - siteCode: string; - fullAddress: string | null; - address: string | null; - city: string | null; - state: string | null; - zip: string | null; -} +export type { Site } from '@proposal-system/api-contracts'; export const sitesApi = { search: async (query: string): Promise => { diff --git a/web/src/pages/admin/customers/CustomerManagementPage.tsx b/web/src/pages/admin/customers/CustomerManagementPage.tsx index f5ec909..bd6a515 100644 --- a/web/src/pages/admin/customers/CustomerManagementPage.tsx +++ b/web/src/pages/admin/customers/CustomerManagementPage.tsx @@ -176,9 +176,12 @@ export default function CustomerManagementPage() { const handleSubmit = () => { if (!validateForm()) return; + // Fix: WEB-M5 — the API contract is addresses: string[] (CustomerDtos.cs); + // the old singular `address` field was silently dropped by model binding. + // The form edits the primary address; any additional addresses are preserved. const payload = { name: form.name.trim(), - address: form.address.trim(), + addresses: [form.address.trim(), ...(editingCustomer?.addresses.slice(1) ?? [])], ...(form.contactEmail.trim() ? { contactEmail: form.contactEmail.trim() } : {}), }; diff --git a/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx b/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx index 099c2a7..ea200c6 100644 --- a/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx +++ b/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx @@ -37,6 +37,7 @@ import { type CreatePricingLibraryItemRequest, type UpdatePricingLibraryItemRequest, } from '../../../lib/api/pricingLibrary'; +import type { ServiceCategory } from '@proposal-system/api-contracts'; import { queryClient } from '../../../lib/queryClient'; import { QUERY_KEYS } from '../../../constants/queryKeys'; import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES } from '../../../constants'; @@ -56,7 +57,7 @@ const CATEGORY_CHIP_STYLES: Record { if (!validateForm()) return; + if (!form.serviceCategory) return; // validateForm guarantees this; narrows '' out of the union const payload = { title: form.title.trim(), diff --git a/web/tsconfig.json b/web/tsconfig.json index 1bc6e12..0651de3 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -15,7 +15,11 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true + "noUncheckedIndexedAccess": true, + "paths": { + "@proposal-system/api-contracts": ["../shared/api-contracts/src"], + "@proposal-system/api-contracts/schemas": ["../shared/api-contracts/src/schemas"] + } }, - "include": ["src"] + "include": ["src", "../shared/api-contracts/src"] } diff --git a/web/vite.config.ts b/web/vite.config.ts index 4953e6a..782b661 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,6 +3,12 @@ import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], + resolve: { + // @proposal-system/api-contracts is a file: symlink; preserve it so its + // 'zod' import resolves from web/node_modules in CI (shared has no + // installed node_modules there). + preserveSymlinks: true, + }, server: { port: 5173, proxy: { From eaba68bae57553eca3d72021e2a3ff692790fc73 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 13 Jul 2026 19:19:57 -0400 Subject: [PATCH 3/3] fix(web): install shared api-contracts deps via postinstall Web Frontend Check failed on PR #222: tsc compiles shared/api-contracts/src/schemas.ts through the tsconfig path alias, and module resolution for its zod import walks up from shared/, never reaching web/node_modules. CI only ran npm ci in web/, so the shared package's deps were absent. A postinstall hook installs them wherever web's deps are installed (CI typecheck, web-test, deploy bundling). Passed locally only because a stray repo-root node_modules/zod satisfied the lookup. --- web/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/web/package.json b/web/package.json index 321cf87..c9b4793 100644 --- a/web/package.json +++ b/web/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "scripts": { + "postinstall": "npm ci --prefix ../shared/api-contracts", "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview",