mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 16:18:57 +00:00
* 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). * 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<T>` (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. * feat(web): domain-layer conventions doc + react-hook-form deps (Phase 4 prep) * feat(web): scaffold domain module layer (proposals, lineItems, customers, pricingLibrary, admin, sites) Additive-only: pages still use lib/api/* and constants/queryKeys.ts until the page-migration agents run. Each domain ships api.ts (HTTP moved from lib/api), types.ts (contract re-exports + view types), schemas.ts (contract schema re-exports + form schemas with toRequest mappers), and use-cases.ts (TanStack Query v5 hooks + hierarchical query keys, mirroring current page invalidations and toast-on-error behavior). Adds an explicit vite/vitest alias for the @proposal-system/api-contracts/schemas subpath (package has no exports map) plus a schema/mapper smoke test suite. * refactor(web): proposal pages on domain layer, proposal form on react-hook-form * refactor(web): admin workspace on domain layer * refactor(web): customer management on domain layer + react-hook-form * refactor(web): pricing library on domain layer + react-hook-form * refactor(web): dashboards on domain layer * test(web): domain use-case hook coverage * refactor(web): finish domain-layer integration — migrate straggler components, delete legacy api modules - SimilarProposalsPanel -> useSimilarProposals (domain/admin); inline SimilarProposal type replaced by domain/admin/types (identical shape); query key joins the admin hierarchical key space - LineItemEditor type imports -> domain/lineItems/types - Delete now-orphaned lib/api/{proposals,lineItems,customers, pricingLibrary,admin,sites}.ts, constants/queryKeys.ts, hooks/usePaginatedList.ts (lib/api/client.ts + auth.ts stay per domain README rule 5) Verify: tsc clean, vitest 52/52, build OK, Playwright smoke of the authed shell renders on domain hooks. * fix(web): apply Phase 4 code-review findings (10 correctness + 4 cleanup) Correctness: - State-transition mutations now invalidate every cached view via invalidateProposalViews (detail + line items + lists + stats + admin dashboard) — approving no longer leaves a stale queue for the 5-minute staleTime - Presigned S3 PUT moved to proposals/api.ts with res.ok check — a rejected upload is no longer confirmed as uploaded - toCustomerRequest always sends contactEmail ('' clears); API create path normalizes empty->null to match the update path — customer emails can now be cleared from the UI - Shared Number-based numeric form fields (domain/shared/formFields): '12abc' no longer silently coerces to 12 in the pricing library - Customer create/update invalidate customersKeys.all so cached search autocompletes see new customers - AdminWorkspace clears dirty right after a successful implicit save, before approve — no false unsaved-changes prompt when approve fails - ProposalFormPage submit gate and missing-fields caption derive from ONE checks list (missing customer is now listed) - Empty states gated on !err in ProposalListPage/AdminDashboard — no contradictory error + 'no proposals' UI - VendorDataPanel migrated to useVendorProposals (kills the divergent ['vendorProposals', id] cache key and the inline apiClient query) - useCustomerList/usePricingLibraryList get keepPreviousData — no TablePagination out-of-range flash on page change Cleanup: - Dead speculative hooks removed (useCreate/BulkUpdate/DeleteLineItem, useUpdateProposal, useProposalHistory/Audit, lineItemRowFormSchema, toUpdateLineItemEntry); tests moved to the live save path (useSaveProposalWorkspace) - Shared useDebouncedValue hook replaces 4 drifted inline debounce copies (one leaked its timer on unmount, two hardcoded 300ms); DEBOUNCE_AUTOCOMPLETE=300 named - Fix: WEB-H5 / WEB-H6 finding-ID markers restored at the relocated onError handlers (CLAUDE.md traceability) - shared/api-contracts gains an exports map; /schemas resolver alias deduplicated from 3 copies to the tsconfig paths mapping Verify: tsc clean, vitest 51/51 (tests updated to pin the new invalidation/mapper behavior + new '12abc' rejection test), vite build OK, dotnet 166/166. * refactor(web): fold Redux auth/ui slices into SHOC-shape auth context + storage module Phase 4 tail of the SHOC-alignment plan. Matches SHOC's auth shape (lib/auth storage module + providers/ context split) while keeping the deliberate divergences: - sessionStorage, not localStorage (WEB-C1 stands; SHOC's localStorage is on the SHOULD-NOT-ALIGN list) - token acquisition stays in the auth pages (Cognito code exchange / dev-login) — the provider only owns session state - 401 interceptor clears storage directly (WEB-M2 behavior preserved; full-page redirect resets provider state) Sidebar open state moves to plain layout state in App passed down as props (SHOC (protected)/_layout.tsx pattern), keeping localStorage persistence. Drops @reduxjs/toolkit and react-redux. Tests: authSlice tests replaced by authStorage + AuthProvider suites (QA-C5 coverage preserved); client interceptor tests updated for the storage-based 401 path. 59 vitest green, tsc clean, vite build OK. Verified end-to-end headless: login redirect, seeded-session shell, sidebar toggle persistence, logout, expired/malformed token handling, RoleGuard bounce; recipe persisted as web/.claude/skills/verify. * fix(web): harden auth session teardown per /sh-security-review findings - AUTH-L1 (confirmed medium): logout() now clears the react-query cache — the singleton cache survived SPA logout, serving the previous principal's cached GETs to the next login in the same tab for up to staleTime with no server round-trip. - AUTH-L3 (confirmed low): isTokenValid decodes base64url before atob — valid Cognito JWTs containing '-'/'_' in the payload segment were misclassified as expired (login lockout/loop; inherited from the old authSlice). - AUTH-L2 (unverified, hardened anyway): 401 interceptor broadcasts AUTH_SESSION_CLEARED_EVENT so AuthProvider drops in-memory state synchronously, restoring the old Redux atomic-clear semantics. - INJ-1 (unverified, hardened anyway): Authorization header only set when the stored token is a string. Each fix pinned by a test; 63 vitest green, tsc clean. * docs: web stack row reflects auth-context refactor (Redux removed, MUI v9) * ci(web): Phase 5 — prettier check + Playwright smoke via org frontend workflow Converts the web CI job from ci-typescript-cdk.yaml (typecheck only) to ci-typescript-frontend.yaml: format:check, build (tsc -b included), vitest, and a Playwright chromium smoke. Folds the standalone Web Tests job into it (aggregator needs updated). Pure CI — no AWS secrets. The smoke (e2e/smoke.spec.ts) drives dev-login → dashboard shell → proposal list, plus the unauthenticated bounce, against a fully mocked API (pathname-anchored route interception — a '**/api/**' glob would swallow vite's /src/lib/api/* module URLs). Config mirrors SHOC's playwright.config.ts (port 4173, chromium, dev-server webServer). Prettier: singleQuote + printWidth 100 to match the existing codebase style; lint intentionally not added (no ESLint config yet — run-lint false, out of Phase 5 scope). rollback = revert this workflow file. * style(web): prettier format pass (mechanical) npx prettier --write . with the new .prettierrc (singleQuote, printWidth 100). No functional changes — enforced by format:check in CI from this PR on.
231 lines
6.8 KiB
TypeScript
231 lines
6.8 KiB
TypeScript
// 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 { Drawer, Box, Tooltip, useMediaQuery, useTheme } from '@mui/material';
|
|
import {
|
|
LayoutDashboard,
|
|
FileText,
|
|
CirclePlus,
|
|
ClipboardList,
|
|
FolderOpen,
|
|
Building2,
|
|
BookOpen,
|
|
Users,
|
|
type LucideIcon,
|
|
} from 'lucide-react';
|
|
import { useAuth } from '../hooks/useAuth';
|
|
import { DRAWER_WIDTH } from '../constants';
|
|
import BrandLockup, { BrandMark } from './brand/BrandLockup';
|
|
|
|
const COLLAPSED_WIDTH = 76; // --spacing-sidebar-collapsed
|
|
|
|
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: 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: NavEntry[] = [{ label: 'Users', path: '/admin/users', icon: Users }];
|
|
|
|
const ALL_NAV_PATHS = new Set([...dispatcherNav, ...adminNav, ...sysadminNav].map((n) => n.path));
|
|
|
|
function isNavActive(pathname: string, itemPath: string): boolean {
|
|
if (pathname === itemPath) return true;
|
|
if (ALL_NAV_PATHS.has(pathname)) return false;
|
|
if (itemPath === '/' || itemPath === '/admin') return false;
|
|
return pathname.startsWith(itemPath + '/');
|
|
}
|
|
|
|
function NavItem({
|
|
item,
|
|
active,
|
|
collapsed,
|
|
onClick,
|
|
}: {
|
|
item: NavEntry;
|
|
active: boolean;
|
|
collapsed: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
const Icon = item.icon;
|
|
const row = (
|
|
<Box
|
|
component="button"
|
|
onClick={onClick}
|
|
sx={{
|
|
position: 'relative',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
gap: '12px',
|
|
width: '100%',
|
|
height: 40,
|
|
mb: '4px',
|
|
px: collapsed ? 0 : '12px',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
borderRadius: 'var(--radius-md)',
|
|
fontFamily: 'var(--font-sans)',
|
|
fontSize: 'var(--text-base)',
|
|
fontWeight: active ? 600 : 500,
|
|
textAlign: 'left',
|
|
color: active ? 'var(--primary)' : 'var(--foreground)',
|
|
backgroundColor: active ? 'var(--color-sidebar-item-active-bg)' : 'transparent',
|
|
boxShadow: active ? 'var(--color-sidebar-item-active-shadow)' : 'none',
|
|
transition: 'all var(--duration-fast) var(--ease-default)',
|
|
'&:hover': active
|
|
? {}
|
|
: {
|
|
color: 'var(--primary-hover)',
|
|
backgroundColor: 'var(--sidebar-accent)',
|
|
},
|
|
'& svg': {
|
|
color: active ? 'var(--primary)' : 'var(--muted-foreground)',
|
|
flexShrink: 0,
|
|
},
|
|
'&:hover svg': active ? {} : { color: 'var(--primary-hover)' },
|
|
}}
|
|
>
|
|
{active && (
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
left: 0,
|
|
top: '8px',
|
|
bottom: '8px',
|
|
width: '3px',
|
|
borderRadius: '0 3px 3px 0',
|
|
backgroundColor: 'var(--primary)',
|
|
}}
|
|
/>
|
|
)}
|
|
<Icon size={19} />
|
|
{!collapsed && (
|
|
<Box
|
|
component="span"
|
|
sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
|
>
|
|
{item.label}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
|
|
if (collapsed) {
|
|
return (
|
|
<Tooltip key={item.path} title={item.label} placement="right" arrow>
|
|
{row}
|
|
</Tooltip>
|
|
);
|
|
}
|
|
return row;
|
|
}
|
|
|
|
export default function Sidebar({
|
|
open,
|
|
onOpenChange,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const theme = useTheme();
|
|
const { user } = useAuth();
|
|
|
|
const isMobile = useMediaQuery(theme.breakpoints.down('md')); // <900px
|
|
|
|
const isAdmin = user?.role === 'Admin' || user?.role === 'SysAdmin';
|
|
const isSysAdmin = user?.role === 'SysAdmin';
|
|
|
|
// 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);
|
|
if (isMobile) {
|
|
onOpenChange(false);
|
|
}
|
|
};
|
|
|
|
const groupLabel = (label: string) => (
|
|
<Box className="text-nav-group" sx={{ px: '12px', pt: '10px', pb: '6px' }}>
|
|
{label}
|
|
</Box>
|
|
);
|
|
|
|
const groupDivider = (
|
|
<Box
|
|
sx={{ height: '1px', mx: '14px', my: '10px', backgroundColor: 'var(--color-sidebar-border)' }}
|
|
/>
|
|
);
|
|
|
|
const renderGroup = (label: string, items: NavEntry[], first: boolean) => (
|
|
<>
|
|
{collapsed ? (first ? null : groupDivider) : groupLabel(label)}
|
|
{items.map((item) => (
|
|
<NavItem
|
|
key={item.path}
|
|
item={item}
|
|
active={isNavActive(location.pathname, item.path)}
|
|
collapsed={collapsed}
|
|
onClick={() => handleNavClick(item.path)}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<Drawer
|
|
variant={isMobile ? 'temporary' : 'permanent'}
|
|
open={isMobile ? open : true}
|
|
onClose={() => onOpenChange(false)}
|
|
sx={{
|
|
width: isMobile ? 0 : width,
|
|
flexShrink: 0,
|
|
'& .MuiDrawer-paper': {
|
|
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)',
|
|
},
|
|
}}
|
|
>
|
|
{/* Brand header row — 64px, white, aligns with the topbar */}
|
|
<Box
|
|
sx={{
|
|
height: 'var(--spacing-topbar)',
|
|
minHeight: 'var(--spacing-topbar)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: 'var(--card)',
|
|
borderBottom: '1px solid var(--color-sidebar-border)',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{collapsed ? <BrandMark /> : <BrandLockup />}
|
|
</Box>
|
|
|
|
<Box sx={{ overflowY: 'auto', overflowX: 'hidden', flexGrow: 1, py: 1, px: '8px' }}>
|
|
{isAdmin && renderGroup('Admin', [...adminNav, ...(isSysAdmin ? sysadminNav : [])], true)}
|
|
{renderGroup('Proposals', dispatcherNav, !isAdmin)}
|
|
</Box>
|
|
</Drawer>
|
|
);
|
|
}
|