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 ( + + + + + setWorkspaceAnchor(null)} + > + {["All", "Everyone else", "Only Me"].map((option) => ( + { + setWorkspace(option); + setWorkspaceAnchor(null); + }} + > + {option} + + ))} + + + + setFilterAnchor(null)} + > + {["Events", "Follow Ups", "Work Orders"].map((option) => ( + { + setEventType(option); + setFilterAnchor(null); + }} + > + {option} + + ))} + + + + + + + + {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 = ( + + + {title || "Capture Signature"} + + + + + + + setSignerName(event.target.value)} + placeholder="Enter signer's full name" + fullWidth + size="small" + /> + + setMode(value)}> + + + + + {mode === "draw" ? ( + + + + + ) : ( + + setTypedName(event.target.value)} + placeholder="Type your name" + fullWidth + size="small" + /> + {typedName && ( + + {typedName} + + )} + + )} + + + + + + + + ); + + 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)", + }, + }} + > + + + + + + setNotifAnchor(null)} + transformOrigin={{ horizontal: "right", vertical: "top" }} + anchorOrigin={{ horizontal: "right", vertical: "bottom" }} + slotProps={{ + paper: { + sx: { width: 300, mt: 1, boxShadow: "var(--shadow-lg)" }, + }, + }} + > + + Notifications + + + + + No new notifications + + + + + setUserAnchor(null)} + transformOrigin={{ horizontal: "right", vertical: "top" }} + anchorOrigin={{ horizontal: "right", vertical: "bottom" }} + slotProps={{ + paper: { + sx: { width: 260, mt: 1, boxShadow: "var(--shadow-lg)" }, + }, + }} + > + + + {userInitials} + + + {user?.name || "User"} + + {user?.email || ""} + + + + + { + setUserAnchor(null); + navigate("/settings/dropdowns"); + }} + > + + + + Settings + + + + + + Logout + + + + ); +} 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 ( + + ); +}