diff --git a/src/components/common/brand-lockup.tsx b/src/components/common/brand-lockup.tsx
new file mode 100644
index 00000000..85a5a517
--- /dev/null
+++ b/src/components/common/brand-lockup.tsx
@@ -0,0 +1,65 @@
+import type { ComponentPropsWithoutRef } from "react";
+
+import { SeahavenMark } from "@/components/common/seahaven-mark";
+import { cn } from "@/lib/utils";
+
+type BrandLockupSize = "default" | "compact";
+
+export type BrandMarkProps = ComponentPropsWithoutRef<"div"> & {
+ size?: BrandLockupSize;
+};
+
+export function BrandMark({ size = "default", className, ...props }: BrandMarkProps) {
+ return (
+
+
+
+ );
+}
+
+export type BrandWordmarkProps = ComponentPropsWithoutRef<"div"> & {
+ size?: BrandLockupSize;
+};
+
+export function BrandWordmark({ size = "default", className, ...props }: BrandWordmarkProps) {
+ return (
+
+
+ SEAHAVEN
+
+
+ OPERATIONS CONSOLE
+
+
+ );
+}
+
+export type BrandLockupProps = ComponentPropsWithoutRef<"div"> & {
+ size?: BrandLockupSize;
+};
+
+export function BrandLockup({ className, size = "default", ...props }: BrandLockupProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/common/calendar/calendar-action-bar.tsx b/src/components/common/calendar/calendar-action-bar.tsx
new file mode 100644
index 00000000..793c88c8
--- /dev/null
+++ b/src/components/common/calendar/calendar-action-bar.tsx
@@ -0,0 +1,124 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import AddIcon from "@mui/icons-material/Add";
+import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
+import ChevronRightIcon from "@mui/icons-material/ChevronRight";
+import FilterListIcon from "@mui/icons-material/FilterList";
+import InboxIcon from "@mui/icons-material/Inbox";
+import { Box, Button, ButtonGroup, Menu, MenuItem, Stack, Typography } from "@mui/material";
+
+interface CalendarActionBarProps {
+ title: string;
+ onTodayClick: () => void;
+ onPrevClick: () => void;
+ onNextClick: () => void;
+ onViewChange: (view: string) => void;
+}
+
+export function CalendarActionBar({
+ title,
+ onTodayClick,
+ onPrevClick,
+ onNextClick,
+ onViewChange,
+}: CalendarActionBarProps) {
+ const navigate = useNavigate();
+ const [workspaceAnchor, setWorkspaceAnchor] = useState(null);
+ const [filterAnchor, setFilterAnchor] = useState(null);
+ const [workspace, setWorkspace] = useState("All");
+ const [eventType, setEventType] = useState("Events");
+
+ return (
+
+
+
+ }
+ onClick={(event) => setWorkspaceAnchor(event.currentTarget)}
+ variant="outlined"
+ size="small"
+ >
+ {workspace}
+
+
+
+ }
+ onClick={(event) => setFilterAnchor(event.currentTarget)}
+ variant="outlined"
+ size="small"
+ >
+ {eventType}
+
+
+
+ }
+ variant="contained"
+ size="small"
+ onClick={() => navigate("/calendar/new")}
+ >
+ New Event
+
+
+
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/common/calendar/calendar-utils.ts b/src/components/common/calendar/calendar-utils.ts
new file mode 100644
index 00000000..13fe5f25
--- /dev/null
+++ b/src/components/common/calendar/calendar-utils.ts
@@ -0,0 +1,151 @@
+import type { CalendarEvent, CalendarEventColor } from "@/domain/calendar/types/calendar-event";
+import type { EventInput } from "@fullcalendar/core";
+
+const COLOR_VAR: Record = {
+ primary: "--primary",
+ success: "--success",
+ info: "--accent",
+ warning: "--warning",
+ danger: "--destructive",
+};
+
+const COLOR_HEX_FALLBACK: Record = {
+ primary: "#1c75bc",
+ success: "#00a79d",
+ info: "#27aae1",
+ warning: "#d97706",
+ danger: "#dc2626",
+};
+
+export function getCalendarColorHex(colorName: string): string {
+ const key = colorName as CalendarEventColor;
+ const cssVar = COLOR_VAR[key] ?? COLOR_VAR.primary;
+
+ if (typeof document !== "undefined") {
+ const value = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim();
+ if (value) {
+ return value;
+ }
+ }
+
+ return COLOR_HEX_FALLBACK[key] ?? COLOR_HEX_FALLBACK.primary;
+}
+
+export function convertTimeToIso(timeStr: string): string {
+ if (!timeStr) {
+ return "00:00:00";
+ }
+
+ const [time, period] = timeStr.split(" ");
+ const [hoursRaw, minutes] = time.split(":");
+ let hours = Number.parseInt(hoursRaw, 10);
+
+ if (period === "PM" && hours !== 12) {
+ hours += 12;
+ } else if (period === "AM" && hours === 12) {
+ hours = 0;
+ }
+
+ return `${hours.toString().padStart(2, "0")}:${minutes}:00`;
+}
+
+export function mapEventToFullCalendar(event: CalendarEvent): EventInput {
+ const startDateOnly = event.startDate?.split("T")[0] ?? event.startDate?.substring(0, 10);
+ const endDateOnly = event.endDate?.split("T")[0] ?? event.endDate?.substring(0, 10);
+
+ let startValue: string;
+ let endValue: string;
+
+ if (event.allDay) {
+ startValue = startDateOnly;
+ const endDate = new Date(endDateOnly);
+ endDate.setDate(endDate.getDate() + 1);
+ endValue = endDate.toISOString().split("T")[0];
+ } else {
+ startValue = `${startDateOnly}T${convertTimeToIso(event.startTime)}`;
+ endValue = `${endDateOnly}T${convertTimeToIso(event.endTime)}`;
+ }
+
+ const color = getCalendarColorHex(event.eventColor);
+
+ return {
+ id: String(event.id),
+ title: event.title,
+ start: startValue,
+ end: endValue,
+ allDay: event.allDay,
+ backgroundColor: color,
+ borderColor: color,
+ extendedProps: {
+ description: event.description,
+ location: event.location,
+ eventColor: event.eventColor,
+ rawEvent: event,
+ },
+ };
+}
+
+export const CALENDAR_TIME_OPTIONS = [
+ "12:00 AM",
+ "12:30 AM",
+ "01:00 AM",
+ "01:30 AM",
+ "02:00 AM",
+ "02:30 AM",
+ "03:00 AM",
+ "03:30 AM",
+ "04:00 AM",
+ "04:30 AM",
+ "05:00 AM",
+ "05:30 AM",
+ "06:00 AM",
+ "06:30 AM",
+ "07:00 AM",
+ "07:30 AM",
+ "08:00 AM",
+ "08:30 AM",
+ "09:00 AM",
+ "09:30 AM",
+ "10:00 AM",
+ "10:30 AM",
+ "11:00 AM",
+ "11:30 AM",
+ "12:00 PM",
+ "12:30 PM",
+ "01:00 PM",
+ "01:30 PM",
+ "02:00 PM",
+ "02:30 PM",
+ "03:00 PM",
+ "03:30 PM",
+ "04:00 PM",
+ "04:30 PM",
+ "05:00 PM",
+ "05:30 PM",
+ "06:00 PM",
+ "06:30 PM",
+ "07:00 PM",
+ "07:30 PM",
+ "08:00 PM",
+ "08:30 PM",
+ "09:00 PM",
+ "09:30 PM",
+ "10:00 PM",
+ "10:30 PM",
+ "11:00 PM",
+ "11:30 PM",
+] as const;
+
+export const CALENDAR_REMINDER_OPTIONS = [
+ { value: 0, label: "Never" },
+ { value: 5, label: "5 minutes before" },
+ { value: 10, label: "10 minutes before" },
+ { value: 15, label: "15 minutes before" },
+ { value: 30, label: "30 minutes before" },
+ { value: 60, label: "1 hour before" },
+ { value: 120, label: "2 hours before" },
+ { value: 240, label: "4 hours before" },
+ { value: 360, label: "6 hours before" },
+ { value: 720, label: "12 hours before" },
+ { value: 1440, label: "1 day before" },
+] as const;
diff --git a/src/components/common/calendar/event-calendar.tsx b/src/components/common/calendar/event-calendar.tsx
new file mode 100644
index 00000000..ca7d11aa
--- /dev/null
+++ b/src/components/common/calendar/event-calendar.tsx
@@ -0,0 +1,56 @@
+import { forwardRef } from "react";
+import FullCalendar from "@fullcalendar/react";
+import dayGridPlugin from "@fullcalendar/daygrid";
+import interactionPlugin from "@fullcalendar/interaction";
+import timeGridPlugin from "@fullcalendar/timegrid";
+import type { DatesSetArg, EventClickArg } from "@fullcalendar/core";
+import type { DateClickArg } from "@fullcalendar/interaction";
+import type { EventInput } from "@fullcalendar/core";
+import { Box, CircularProgress } from "@mui/material";
+
+interface EventCalendarProps {
+ events: EventInput[];
+ loading?: boolean;
+ onDatesSet: (info: DatesSetArg) => void;
+ onEventClick: (info: EventClickArg) => void;
+ onDateClick: (info: DateClickArg) => void;
+}
+
+export const EventCalendar = forwardRef(function EventCalendar(
+ { events, loading, onDatesSet, onEventClick, onDateClick },
+ ref,
+) {
+ return (
+
+ {loading && (
+
+
+
+ )}
+
+
+
+ );
+});
diff --git a/src/components/common/seahaven-mark.tsx b/src/components/common/seahaven-mark.tsx
new file mode 100644
index 00000000..ea460057
--- /dev/null
+++ b/src/components/common/seahaven-mark.tsx
@@ -0,0 +1,24 @@
+import type { ComponentPropsWithoutRef } from "react";
+
+import { cn } from "@/lib/utils";
+
+export function SeahavenMark({ className, ...props }: ComponentPropsWithoutRef<"svg">) {
+ return (
+
+ );
+}
diff --git a/src/components/common/settings-nav.tsx b/src/components/common/settings-nav.tsx
new file mode 100644
index 00000000..2a9add36
--- /dev/null
+++ b/src/components/common/settings-nav.tsx
@@ -0,0 +1,29 @@
+import { NavLink } from "react-router-dom";
+import { Stack } from "@mui/material";
+
+const SETTINGS_LINKS = [
+ { path: "/settings/dropdowns", label: "Dropdown Options" },
+ { path: "/settings/task-templates", label: "Task Templates" },
+] as const;
+
+export function SettingsNav() {
+ return (
+
+ {SETTINGS_LINKS.map((link) => (
+
+ `px-5 py-2 text-sm font-semibold no-underline border-b-2 -mb-0.5 transition-colors ${
+ isActive
+ ? "text-primary border-primary"
+ : "text-muted-foreground border-transparent hover:text-foreground"
+ }`
+ }
+ >
+ {link.label}
+
+ ))}
+
+ );
+}
diff --git a/src/components/common/signature-capture.tsx b/src/components/common/signature-capture.tsx
new file mode 100644
index 00000000..c08123c2
--- /dev/null
+++ b/src/components/common/signature-capture.tsx
@@ -0,0 +1,214 @@
+import { CSS_VAR_FALLBACKS, getCssVar } from "@/lib/theme/css-vars";
+import { useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import CloseIcon from "@mui/icons-material/Close";
+import {
+ Box,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ IconButton,
+ Tab,
+ Tabs,
+ TextField,
+ Typography,
+} from "@mui/material";
+
+interface SignatureCaptureProps {
+ title?: string;
+ onClose: () => void;
+ onSave: (signature: string, method: "drawn" | "typed", name: string) => void;
+}
+
+export function SignatureCapture({ onClose, onSave, title }: SignatureCaptureProps) {
+ const [mode, setMode] = useState<"draw" | "type">("draw");
+ const [typedName, setTypedName] = useState("");
+ const [signerName, setSignerName] = useState("");
+ const canvasRef = useRef(null);
+ const isDrawing = useRef(false);
+
+ useEffect(() => {
+ if (mode === "draw" && canvasRef.current) {
+ const canvas = canvasRef.current;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ return;
+ }
+ ctx.fillStyle = getCssVar("--background", CSS_VAR_FALLBACKS.background);
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.strokeStyle = getCssVar("--foreground", CSS_VAR_FALLBACKS.foreground);
+ ctx.lineWidth = 2;
+ ctx.lineCap = "round";
+ }
+ }, [mode]);
+
+ const getPos = (event: React.MouseEvent | React.TouchEvent) => {
+ const canvas = canvasRef.current;
+ if (!canvas) {
+ return { x: 0, y: 0 };
+ }
+
+ const rect = canvas.getBoundingClientRect();
+ const touch = "touches" in event ? event.touches[0] : event;
+ if (!touch) {
+ return { x: 0, y: 0 };
+ }
+
+ return { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
+ };
+
+ const startDraw = (event: React.MouseEvent | React.TouchEvent) => {
+ event.preventDefault();
+ const canvas = canvasRef.current;
+ if (!canvas) {
+ return;
+ }
+
+ isDrawing.current = true;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ return;
+ }
+
+ const pos = getPos(event);
+ ctx.beginPath();
+ ctx.moveTo(pos.x, pos.y);
+ };
+
+ const draw = (event: React.MouseEvent | React.TouchEvent) => {
+ event.preventDefault();
+ if (!isDrawing.current || !canvasRef.current) {
+ return;
+ }
+
+ const ctx = canvasRef.current.getContext("2d");
+ if (!ctx) {
+ return;
+ }
+
+ const pos = getPos(event);
+ ctx.lineTo(pos.x, pos.y);
+ ctx.stroke();
+ };
+
+ const stopDraw = () => {
+ isDrawing.current = false;
+ };
+
+ const clearCanvas = () => {
+ const canvas = canvasRef.current;
+ if (!canvas) {
+ return;
+ }
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ return;
+ }
+
+ ctx.fillStyle = getCssVar("--background", CSS_VAR_FALLBACKS.background);
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ };
+
+ const handleSave = () => {
+ if (!signerName.trim()) {
+ return;
+ }
+
+ if (mode === "draw") {
+ const canvas = canvasRef.current;
+ if (!canvas) {
+ return;
+ }
+ onSave(canvas.toDataURL("image/png"), "drawn", signerName.trim());
+ return;
+ }
+
+ if (!typedName.trim()) {
+ return;
+ }
+
+ onSave(typedName, "typed", signerName.trim());
+ };
+
+ const dialog = (
+
+ );
+
+ return createPortal(dialog, document.body);
+}
diff --git a/src/components/domain/doc-badge.tsx b/src/components/domain/doc-badge.tsx
new file mode 100644
index 00000000..b9bdfca3
--- /dev/null
+++ b/src/components/domain/doc-badge.tsx
@@ -0,0 +1,53 @@
+import CheckIcon from "@mui/icons-material/Check";
+import CloseIcon from "@mui/icons-material/Close";
+import RemoveIcon from "@mui/icons-material/Remove";
+
+export type DocVariant = "yes" | "no" | "nn";
+
+const DOC_TOKENS: Record = {
+ yes: { bg: "var(--doc-yes-bg)", text: "var(--doc-yes-text)" },
+ no: { bg: "var(--doc-no-bg)", text: "var(--doc-no-text)" },
+ nn: { bg: "var(--doc-nn-bg)", text: "var(--doc-nn-text)" },
+};
+
+const DOC_LABELS: Record = {
+ yes: "Yes",
+ no: "No",
+ nn: "N/A",
+};
+
+type DocBadgeProps = {
+ variant: DocVariant;
+ label?: string;
+ showIcon?: boolean;
+};
+
+export function DocBadge({ variant, label, showIcon = true }: DocBadgeProps) {
+ const tokens = DOC_TOKENS[variant];
+
+ const icon =
+ variant === "yes" ? (
+
+ ) : variant === "no" ? (
+
+ ) : (
+
+ );
+
+ return (
+
+ {showIcon && icon}
+ {label ?? DOC_LABELS[variant]}
+
+ );
+}
diff --git a/src/components/domain/status-badge-utils.ts b/src/components/domain/status-badge-utils.ts
new file mode 100644
index 00000000..2beabe95
--- /dev/null
+++ b/src/components/domain/status-badge-utils.ts
@@ -0,0 +1,31 @@
+import type { StatusVariant } from "./status-badge";
+
+/** Maps API / legacy status strings to Seahaven status variants. */
+const STATUS_ALIASES: Record = {
+ open: "incomplete",
+ incomplete: "incomplete",
+ pending: "pending",
+ scheduled: "scheduled",
+ "in route": "inroute",
+ inroute: "inroute",
+ "on site": "onsite",
+ onsite: "onsite",
+ "in progress": "inprogress",
+ inprogress: "inprogress",
+ done: "completed",
+ completed: "completed",
+ complete: "completed",
+ rescheduled: "rescheduled",
+ cancelled: "canceled",
+ canceled: "canceled",
+ "on hold": "pending",
+ "pending quote": "pendingquote",
+ pendingquote: "pendingquote",
+ "past due": "pastdue",
+ pastdue: "pastdue",
+};
+
+export function resolveStatusVariant(status: string): StatusVariant | null {
+ const key = status.trim().toLowerCase();
+ return STATUS_ALIASES[key] ?? null;
+}
diff --git a/src/components/domain/status-badge.tsx b/src/components/domain/status-badge.tsx
new file mode 100644
index 00000000..d7a0aac8
--- /dev/null
+++ b/src/components/domain/status-badge.tsx
@@ -0,0 +1,84 @@
+import { resolveStatusVariant } from "./status-badge-utils";
+
+export type StatusVariant =
+ | "incomplete"
+ | "pending"
+ | "scheduled"
+ | "inroute"
+ | "onsite"
+ | "inprogress"
+ | "completed"
+ | "rescheduled"
+ | "canceled"
+ | "pendingquote"
+ | "pastdue";
+
+const STATUS_TOKENS: Record = {
+ incomplete: { bg: "var(--status-incomplete-bg)", text: "var(--status-incomplete-text)" },
+ pending: { bg: "var(--status-pending-bg)", text: "var(--status-pending-text)" },
+ scheduled: { bg: "var(--status-scheduled-bg)", text: "var(--status-scheduled-text)" },
+ inroute: { bg: "var(--status-inroute-bg)", text: "var(--status-inroute-text)" },
+ onsite: { bg: "var(--status-onsite-bg)", text: "var(--status-onsite-text)" },
+ inprogress: { bg: "var(--status-inprogress-bg)", text: "var(--status-inprogress-text)" },
+ completed: { bg: "var(--status-completed-bg)", text: "var(--status-completed-text)" },
+ rescheduled: { bg: "var(--status-rescheduled-bg)", text: "var(--status-rescheduled-text)" },
+ canceled: { bg: "var(--status-canceled-bg)", text: "var(--status-canceled-text)" },
+ pendingquote: { bg: "var(--status-pendingquote-bg)", text: "var(--status-pendingquote-text)" },
+ pastdue: { bg: "var(--status-pastdue-bg)", text: "var(--status-pastdue-text)" },
+};
+
+const STATUS_LABELS: Record = {
+ incomplete: "Incomplete",
+ pending: "Pending",
+ scheduled: "Scheduled",
+ inroute: "In Route",
+ onsite: "On Site",
+ inprogress: "In Progress",
+ completed: "Completed",
+ rescheduled: "Rescheduled",
+ canceled: "Canceled",
+ pendingquote: "Pending Quote",
+ pastdue: "Past Due",
+};
+
+type StatusBadgeProps = {
+ status: StatusVariant;
+ label?: string;
+};
+
+export function StatusBadge({ status, label }: StatusBadgeProps) {
+ const tokens = STATUS_TOKENS[status];
+
+ return (
+
+ {label ?? STATUS_LABELS[status]}
+
+ );
+}
+
+type WorkOrderStatusBadgeProps = {
+ status: string;
+};
+
+/** Renders StatusBadge when status maps to catalog; otherwise shows raw label with incomplete styling. */
+export function WorkOrderStatusBadge({ status }: WorkOrderStatusBadgeProps) {
+ const label = status || "Open";
+ const variant = resolveStatusVariant(label);
+
+ if (variant) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/src/components/domain/type-badge-utils.ts b/src/components/domain/type-badge-utils.ts
new file mode 100644
index 00000000..27ecf85d
--- /dev/null
+++ b/src/components/domain/type-badge-utils.ts
@@ -0,0 +1,23 @@
+import type { TypeVariant } from "./type-badge";
+
+const COMPANY_TYPE_MAP: Record = {
+ Vendor: "reactive",
+ "In-house": "pm",
+};
+
+const SERVICE_TYPE_MAP: Record = {
+ "Scrubber Pit": "pm",
+ "Backflow Preventers": "pm",
+ "Tank/Water Heater": "reactive",
+ "Floor Drain Backing Up": "emergency",
+ "HVAC Inspection": "addon",
+ "Parking Lot Lighting": "reactive",
+ "Pumping Systems": "addon",
+ "Door/Hardware": "pm",
+ "Pressure Reducing Valve": "emergency",
+ "Fire Suppression System": "emergency",
+};
+
+export function resolveTypeVariant(type: string): TypeVariant {
+ return COMPANY_TYPE_MAP[type] ?? SERVICE_TYPE_MAP[type] ?? "pm";
+}
diff --git a/src/components/domain/type-badge.tsx b/src/components/domain/type-badge.tsx
new file mode 100644
index 00000000..df5db8ca
--- /dev/null
+++ b/src/components/domain/type-badge.tsx
@@ -0,0 +1,39 @@
+export type TypeVariant = "pm" | "reactive" | "emergency" | "addon";
+
+const TYPE_TOKENS: Record = {
+ pm: { bg: "var(--type-pm-bg)", text: "var(--type-pm-text)" },
+ reactive: { bg: "var(--type-reactive-bg)", text: "var(--type-reactive-text)" },
+ emergency: { bg: "var(--type-emergency-bg)", text: "var(--type-emergency-text)" },
+ addon: { bg: "var(--type-addon-bg)", text: "var(--type-addon-text)" },
+};
+
+import { resolveTypeVariant } from "./type-badge-utils";
+
+type TypeBadgeProps = {
+ label: string;
+ variant?: TypeVariant;
+ /** Auto-resolve variant from known company or service type labels */
+ type?: string;
+};
+
+export function TypeBadge({ label, variant, type }: TypeBadgeProps) {
+ const resolved = variant ?? (type ? resolveTypeVariant(type) : "pm");
+ const tokens = TYPE_TOKENS[resolved];
+
+ return (
+
+ {label}
+
+ );
+}
diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx
new file mode 100644
index 00000000..c16f3950
--- /dev/null
+++ b/src/components/layout/app-sidebar.tsx
@@ -0,0 +1,240 @@
+import {
+ FaAddressBook,
+ FaBell,
+ FaCalendarAlt,
+ FaClipboardList,
+ FaCog,
+ FaCubes,
+ FaDollarSign,
+ FaFileInvoiceDollar,
+ FaMapMarkerAlt,
+ FaMoneyCheckAlt,
+ FaTachometerAlt,
+ FaTools,
+ FaTruck,
+ FaUserTie,
+} from "react-icons/fa";
+import type { IconType } from "react-icons";
+import { NavLink } from "react-router-dom";
+import {
+ Box,
+ Drawer,
+ List,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
+ Typography,
+} from "@mui/material";
+
+import { cn } from "@/lib/utils";
+
+const SIDEBAR_WIDTH = 220;
+const SIDEBAR_COLLAPSED_WIDTH = 56;
+
+type NavItemDef = {
+ label: string;
+ icon: IconType;
+ path: string;
+};
+
+const navGroups: { title: string; items: NavItemDef[] }[] = [
+ {
+ title: "Overview",
+ items: [
+ { label: "Dashboard", icon: FaTachometerAlt, path: "/" },
+ { label: "Calendar", icon: FaCalendarAlt, path: "/calendar" },
+ ],
+ },
+ {
+ title: "Operations",
+ items: [
+ { label: "Work Orders", icon: FaTools, path: "/workorders" },
+ { label: "Uplifts", icon: FaDollarSign, path: "/uplifts" },
+ { label: "Follow Ups", icon: FaBell, path: "/followups" },
+ { label: "PM Schedules", icon: FaClipboardList, path: "/pmschedules" },
+ ],
+ },
+ {
+ title: "Vendors",
+ items: [
+ { label: "Vendors", icon: FaTruck, path: "/vendors" },
+ { label: "Vendor POs", icon: FaFileInvoiceDollar, path: "/vendor-pos" },
+ ],
+ },
+ {
+ title: "Customers & Sites",
+ items: [
+ { label: "Accounts", icon: FaMoneyCheckAlt, path: "/accounts" },
+ { label: "Contacts", icon: FaAddressBook, path: "/contacts" },
+ { label: "Locations", icon: FaMapMarkerAlt, path: "/locations" },
+ { label: "Assets", icon: FaCubes, path: "/assets" },
+ ],
+ },
+ {
+ title: "Internal",
+ items: [{ label: "Employees", icon: FaUserTie, path: "/employees" }],
+ },
+ {
+ title: "System",
+ items: [{ label: "Settings", icon: FaCog, path: "/settings/dropdowns" }],
+ },
+];
+
+type NavItemProps = NavItemDef & {
+ collapsed: boolean;
+ end?: boolean;
+};
+
+function NavItem({ label, icon: Icon, path, collapsed, end }: NavItemProps) {
+ return (
+
+ {({ isActive }) => (
+
+
+
+
+ {!collapsed && (
+
+ )}
+
+ )}
+
+ );
+}
+
+type AppSidebarProps = {
+ collapsed: boolean;
+};
+
+export function AppSidebar({ collapsed }: AppSidebarProps) {
+ const width = collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH;
+
+ return (
+
+
+
+
+
+
+
+ {navGroups.map((group, groupIndex) => (
+
+ {!collapsed && (
+
+ {group.title}
+
+ )}
+
+ {collapsed && groupIndex > 0 && (
+
+ )}
+
+
+ {group.items.map(({ label, icon, path }) => (
+
+ ))}
+
+
+ ))}
+
+
+
+ );
+}
+
+export { SIDEBAR_COLLAPSED_WIDTH, SIDEBAR_WIDTH };
diff --git a/src/components/layout/app-topbar.tsx b/src/components/layout/app-topbar.tsx
new file mode 100644
index 00000000..7266e47e
--- /dev/null
+++ b/src/components/layout/app-topbar.tsx
@@ -0,0 +1,198 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
+import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined";
+import MenuIcon from "@mui/icons-material/Menu";
+import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined";
+import PersonOutlineOutlinedIcon from "@mui/icons-material/PersonOutlineOutlined";
+import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
+import {
+ AppBar,
+ Avatar,
+ Box,
+ Divider,
+ IconButton,
+ ListItemIcon,
+ ListItemText,
+ Menu,
+ MenuItem,
+ Toolbar,
+ Typography,
+} from "@mui/material";
+
+import { useAuthContext } from "@/providers/auth-context";
+
+type AppTopbarProps = {
+ onToggleNav: () => void;
+};
+
+export function AppTopbar({ onToggleNav }: AppTopbarProps) {
+ const navigate = useNavigate();
+ const { user, logout } = useAuthContext();
+
+ const [notifAnchor, setNotifAnchor] = useState(null);
+ const [userAnchor, setUserAnchor] = useState(null);
+
+ const handleLogout = () => {
+ setUserAnchor(null);
+ logout();
+ };
+
+ const userInitials = user?.name
+ ? user.name
+ .split(" ")
+ .map((part) => part[0])
+ .join("")
+ .slice(0, 2)
+ .toUpperCase()
+ : "U";
+
+ return (
+
+
+
+
+
+
+
+
+
+ setNotifAnchor(event.currentTarget)}
+ sx={{
+ color: "var(--color-header-text-muted)",
+ "&:hover": {
+ color: "var(--color-header-text)",
+ backgroundColor: "var(--color-header-chip-hover)",
+ },
+ }}
+ >
+
+
+
+
+
+ setUserAnchor(event.currentTarget)}
+ sx={{
+ color: "var(--color-header-text-muted)",
+ "&:hover": {
+ color: "var(--color-header-text)",
+ backgroundColor: "var(--color-header-chip-hover)",
+ },
+ }}
+ >
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 00000000..e700985a
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,21 @@
+import type { ChipProps } from "@mui/material";
+import { Chip } from "@mui/material";
+
+import { cn } from "@/lib/utils";
+
+export type BadgeProps = ChipProps;
+
+export function Badge({ className, size = "small", ...props }: BadgeProps) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 00000000..9813b3f7
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,68 @@
+import type { ButtonProps } from "@mui/material";
+import { Button as MuiButton, CircularProgress } from "@mui/material";
+
+import { cn } from "@/lib/utils";
+
+type ButtonVariant = "default" | "destructive" | "outline" | "ghost" | "link";
+type ButtonSize = "default" | "lg";
+
+export type UiButtonProps = Omit & {
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+ loading?: boolean;
+ loadingText?: string;
+};
+
+const variantMap: Record<
+ ButtonVariant,
+ Pick & { className?: string }
+> = {
+ default: { variant: "contained", color: "primary" },
+ destructive: { variant: "contained", color: "error" },
+ outline: { variant: "outlined", color: "primary", className: "border-border" },
+ ghost: { variant: "text", color: "inherit", className: "text-foreground" },
+ link: { variant: "text", color: "primary", className: "underline-offset-4 hover:underline" },
+};
+
+const sizeSx: Record = {
+ default: undefined,
+ lg: {
+ height: 42,
+ fontFamily: "var(--font-display)",
+ fontSize: "var(--text-base)",
+ fontWeight: 600,
+ },
+};
+
+export function Button({
+ variant = "default",
+ size = "default",
+ loading = false,
+ loadingText = "Loading…",
+ className,
+ disabled,
+ children,
+ sx,
+ ...props
+}: UiButtonProps) {
+ const mapped = variantMap[variant];
+
+ return (
+
+ {loading ? (
+ <>
+
+ {loadingText}
+ >
+ ) : (
+ children
+ )}
+
+ );
+}
diff --git a/src/components/ui/empty-state.tsx b/src/components/ui/empty-state.tsx
new file mode 100644
index 00000000..42495fe6
--- /dev/null
+++ b/src/components/ui/empty-state.tsx
@@ -0,0 +1,34 @@
+import type { ReactNode } from "react";
+import { Box, Typography } from "@mui/material";
+
+import { cn } from "@/lib/utils";
+
+type EmptyStateProps = {
+ icon?: ReactNode;
+ title: string;
+ description?: string;
+ action?: ReactNode;
+ className?: string;
+};
+
+export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
+ return (
+
+ {icon && {icon}}
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+ {action && {action}}
+
+ );
+}
diff --git a/src/components/ui/form-error-message.tsx b/src/components/ui/form-error-message.tsx
new file mode 100644
index 00000000..8fe6954d
--- /dev/null
+++ b/src/components/ui/form-error-message.tsx
@@ -0,0 +1,24 @@
+import type { ComponentPropsWithoutRef } from "react";
+import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
+import { Typography } from "@mui/material";
+
+import { cn } from "@/lib/utils";
+
+export type FormErrorMessageProps = ComponentPropsWithoutRef<"div"> & {
+ message: string;
+};
+
+export function FormErrorMessage({ message, className, ...props }: FormErrorMessageProps) {
+ return (
+
+
+
+ {message}
+
+
+ );
+}
diff --git a/src/components/ui/form-field.tsx b/src/components/ui/form-field.tsx
new file mode 100644
index 00000000..7d9d5dce
--- /dev/null
+++ b/src/components/ui/form-field.tsx
@@ -0,0 +1,70 @@
+import type { ReactNode } from "react";
+import { FormControl, FormHelperText, FormLabel, type FormControlProps } from "@mui/material";
+
+import { cn } from "@/lib/utils";
+
+type FormFieldAppearance = "default" | "auth";
+
+type FormFieldProps = FormControlProps & {
+ label: string;
+ htmlFor?: string;
+ helperText?: string;
+ error?: boolean;
+ required?: boolean;
+ children: ReactNode;
+ className?: string;
+ appearance?: FormFieldAppearance;
+};
+
+const appearanceClassName: Record = {
+ default: "gap-1",
+ auth: "gap-1.5 [&_.MuiFormLabel-root]:mb-0 [&_.MuiFormLabel-root]:!font-sans [&_.MuiFormLabel-root]:![font-size:var(--text-base-sm)] [&_.MuiFormLabel-root]:!font-medium [&_.MuiFormLabel-root]:!leading-[1.5] [&_.MuiFormLabel-root]:!normal-case [&_.MuiFormLabel-root]:!tracking-normal",
+};
+
+const appearanceLabelClassName: Record = {
+ default: "text-label static mb-1 text-foreground normal-case",
+ auth: "static text-foreground normal-case",
+};
+
+const appearanceLabelSx: Record = {
+ default: {
+ fontSize: "0.8125rem",
+ fontWeight: 600,
+ letterSpacing: "normal",
+ textTransform: "none",
+ lineHeight: 1.5,
+ },
+ auth: undefined,
+};
+
+export function FormField({
+ label,
+ htmlFor,
+ helperText,
+ error,
+ required,
+ children,
+ className,
+ appearance = "default",
+ ...props
+}: FormFieldProps) {
+ return (
+
+
+ {label}
+
+ {children}
+ {helperText && {helperText}}
+
+ );
+}
diff --git a/src/components/ui/page-header.tsx b/src/components/ui/page-header.tsx
new file mode 100644
index 00000000..2ae6ec98
--- /dev/null
+++ b/src/components/ui/page-header.tsx
@@ -0,0 +1,45 @@
+import type { ReactNode } from "react";
+import { Stack, Typography } from "@mui/material";
+
+import { cn } from "@/lib/utils";
+
+type PageHeaderProps = {
+ eyebrow?: string;
+ title: string;
+ subtitle?: string;
+ actions?: ReactNode;
+ className?: string;
+};
+
+export function PageHeader({ eyebrow, title, subtitle, actions, className }: PageHeaderProps) {
+ return (
+
+
+ {eyebrow && (
+
+ {eyebrow}
+
+ )}
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ {actions && (
+
+ {actions}
+
+ )}
+
+ );
+}
diff --git a/src/components/ui/password-field.tsx b/src/components/ui/password-field.tsx
new file mode 100644
index 00000000..8f10fc31
--- /dev/null
+++ b/src/components/ui/password-field.tsx
@@ -0,0 +1,68 @@
+import { useState } from "react";
+import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
+import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
+import { IconButton, InputAdornment } from "@mui/material";
+
+import { TextField, type UiTextFieldProps } from "@/components/ui/text-field";
+
+export type PasswordFieldProps = Omit & {
+ visible?: boolean;
+ onVisibleChange?: (visible: boolean) => void;
+};
+
+const passwordFieldSx = {
+ "& .MuiInputAdornment-positionEnd": {
+ marginRight: "-8px",
+ },
+} as const;
+
+export function PasswordField({
+ visible: visibleProp,
+ onVisibleChange,
+ sx,
+ slotProps,
+ ...props
+}: PasswordFieldProps) {
+ const [internalVisible, setInternalVisible] = useState(false);
+ const isControlled = visibleProp !== undefined;
+ const showPassword = isControlled ? visibleProp : internalVisible;
+
+ const toggleVisibility = () => {
+ const next = !showPassword;
+ if (!isControlled) {
+ setInternalVisible(next);
+ }
+ onVisibleChange?.(next);
+ };
+
+ return (
+
+
+ {showPassword ? (
+
+ ) : (
+
+ )}
+
+
+ ),
+ },
+ }}
+ {...props}
+ />
+ );
+}
diff --git a/src/components/ui/text-field.tsx b/src/components/ui/text-field.tsx
new file mode 100644
index 00000000..f1a8e15c
--- /dev/null
+++ b/src/components/ui/text-field.tsx
@@ -0,0 +1,51 @@
+import type { TextFieldProps } from "@mui/material";
+import { TextField as MuiTextField } from "@mui/material";
+
+import { cn } from "@/lib/utils";
+
+export type UiTextFieldProps = TextFieldProps;
+
+const textFieldSx = {
+ "& .MuiOutlinedInput-root": {
+ height: 40,
+ fontFamily: "var(--font-sans)",
+ fontSize: "var(--text-base)",
+ },
+ "& .MuiInputBase-input": {
+ fontFamily: "var(--font-sans)",
+ fontSize: "var(--text-base)",
+ color: "var(--foreground)",
+ "&::placeholder": {
+ color: "var(--placeholder-foreground)",
+ opacity: 1,
+ },
+ "&::-webkit-input-placeholder": {
+ color: "var(--placeholder-foreground)",
+ opacity: 1,
+ },
+ "&::-moz-placeholder": {
+ color: "var(--placeholder-foreground)",
+ opacity: 1,
+ },
+ },
+} as const;
+
+export function TextField({
+ className,
+ fullWidth = true,
+ hiddenLabel = true,
+ size = "small",
+ sx,
+ ...props
+}: UiTextFieldProps) {
+ return (
+
+ );
+}