feat: add shared UI, layout and domain badge components

This commit is contained in:
Arthur Bassi 2026-06-12 11:52:07 -03:00
parent 49fbc98dd0
commit ddc174c204
22 changed files with 1712 additions and 0 deletions

View file

@ -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 (
<div
className={cn(
"flex shrink-0 items-center justify-center bg-linear-to-br from-accent to-primary text-white shadow-(--shadow-brand-mark)",
size === "compact" ? "h-[26px] w-[26px] rounded-[7px]" : "h-[30px] w-[30px] rounded-lg",
className,
)}
{...props}
>
<SeahavenMark className={size === "compact" ? "h-4 w-4" : "h-[18px] w-[18px]"} />
</div>
);
}
export type BrandWordmarkProps = ComponentPropsWithoutRef<"div"> & {
size?: BrandLockupSize;
};
export function BrandWordmark({ size = "default", className, ...props }: BrandWordmarkProps) {
return (
<div className={cn("leading-none", className)} {...props}>
<div
className={cn(
"font-display font-extrabold tracking-[0.04em] text-foreground",
size === "compact" ? "text-md" : "text-[16px]",
)}
>
SEAHAVEN
</div>
<div
className={cn(
"mt-[3px] font-mono font-semibold tracking-[0.16em] text-muted-foreground",
size === "compact" ? "text-[7.5px]" : "text-[8px]",
)}
>
OPERATIONS CONSOLE
</div>
</div>
);
}
export type BrandLockupProps = ComponentPropsWithoutRef<"div"> & {
size?: BrandLockupSize;
};
export function BrandLockup({ className, size = "default", ...props }: BrandLockupProps) {
return (
<div className={cn("flex items-center justify-center gap-2.5", className)} {...props}>
<BrandMark size={size} />
<BrandWordmark size={size} />
</div>
);
}

View file

@ -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<HTMLElement | null>(null);
const [filterAnchor, setFilterAnchor] = useState<HTMLElement | null>(null);
const [workspace, setWorkspace] = useState("All");
const [eventType, setEventType] = useState("Events");
return (
<Box className="mb-4 rounded-lg border border-border bg-card p-3">
<Stack
direction={{ xs: "column", lg: "row" }}
spacing={2}
alignItems={{ xs: "stretch", lg: "center" }}
justifyContent="space-between"
>
<Stack direction="row" spacing={1} flexWrap="wrap" alignItems="center">
<Button
startIcon={<InboxIcon />}
onClick={(event) => setWorkspaceAnchor(event.currentTarget)}
variant="outlined"
size="small"
>
{workspace}
</Button>
<Menu
anchorEl={workspaceAnchor}
open={Boolean(workspaceAnchor)}
onClose={() => setWorkspaceAnchor(null)}
>
{["All", "Everyone else", "Only Me"].map((option) => (
<MenuItem
key={option}
onClick={() => {
setWorkspace(option);
setWorkspaceAnchor(null);
}}
>
{option}
</MenuItem>
))}
</Menu>
<Button
startIcon={<FilterListIcon />}
onClick={(event) => setFilterAnchor(event.currentTarget)}
variant="outlined"
size="small"
>
{eventType}
</Button>
<Menu
anchorEl={filterAnchor}
open={Boolean(filterAnchor)}
onClose={() => setFilterAnchor(null)}
>
{["Events", "Follow Ups", "Work Orders"].map((option) => (
<MenuItem
key={option}
onClick={() => {
setEventType(option);
setFilterAnchor(null);
}}
>
{option}
</MenuItem>
))}
</Menu>
<Button
startIcon={<AddIcon />}
variant="contained"
size="small"
onClick={() => navigate("/calendar/new")}
>
New Event
</Button>
</Stack>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
<Typography variant="h6" className="min-w-[180px]">
{title}
</Typography>
<Button size="small" onClick={onTodayClick}>
Today
</Button>
<Button size="small" onClick={onPrevClick}>
<ChevronLeftIcon />
</Button>
<Button size="small" onClick={onNextClick}>
<ChevronRightIcon />
</Button>
<ButtonGroup size="small" variant="outlined">
<Button onClick={() => onViewChange("dayGridMonth")}>Month</Button>
<Button onClick={() => onViewChange("timeGridWeek")}>Week</Button>
<Button onClick={() => onViewChange("timeGridDay")}>Day</Button>
</ButtonGroup>
</Stack>
</Stack>
</Box>
);
}

View file

@ -0,0 +1,151 @@
import type { CalendarEvent, CalendarEventColor } from "@/domain/calendar/types/calendar-event";
import type { EventInput } from "@fullcalendar/core";
const COLOR_VAR: Record<CalendarEventColor, string> = {
primary: "--primary",
success: "--success",
info: "--accent",
warning: "--warning",
danger: "--destructive",
};
const COLOR_HEX_FALLBACK: Record<CalendarEventColor, string> = {
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;

View file

@ -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<FullCalendar, EventCalendarProps>(function EventCalendar(
{ events, loading, onDatesSet, onEventClick, onDateClick },
ref,
) {
return (
<Box className="relative rounded-lg border border-border bg-card p-3">
{loading && (
<Box className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-white/70">
<CircularProgress size={32} />
</Box>
)}
<FullCalendar
ref={ref}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
headerToolbar={false}
events={events}
editable={false}
selectable
selectMirror
dayMaxEvents
weekends
datesSet={onDatesSet}
eventClick={onEventClick}
dateClick={onDateClick}
height="auto"
fixedWeekCount={false}
showNonCurrentDates
eventTimeFormat={{
hour: "2-digit",
minute: "2-digit",
meridiem: "short",
}}
/>
</Box>
);
});

View file

@ -0,0 +1,24 @@
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "@/lib/utils";
export function SeahavenMark({ className, ...props }: ComponentPropsWithoutRef<"svg">) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={cn(className)}
{...props}
>
<path d="M2 12q2.5 2 5 0t5 0 5 0 5 0" />
<path d="M2 19q2.5 2 5 0t5 0 5 0 5 0" />
<path d="M2 5q2.5 2 5 0t5 0 5 0 5 0" />
</svg>
);
}

View file

@ -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 (
<Stack direction="row" className="mb-5 border-b-2 border-border">
{SETTINGS_LINKS.map((link) => (
<NavLink
key={link.path}
to={link.path}
className={({ isActive }) =>
`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}
</NavLink>
))}
</Stack>
);
}

View file

@ -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<HTMLCanvasElement>(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 = (
<Dialog open onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle className="flex items-center justify-between pr-2">
{title || "Capture Signature"}
<IconButton aria-label="Close" onClick={onClose} size="small">
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent className="flex flex-col gap-4">
<TextField
label="Full Name"
value={signerName}
onChange={(event) => setSignerName(event.target.value)}
placeholder="Enter signer's full name"
fullWidth
size="small"
/>
<Tabs value={mode} onChange={(_event, value: "draw" | "type") => setMode(value)}>
<Tab label="Draw" value="draw" />
<Tab label="Type" value="type" />
</Tabs>
{mode === "draw" ? (
<Box className="relative">
<canvas
ref={canvasRef}
width={460}
height={180}
className="block w-full cursor-crosshair rounded border border-border bg-background"
onMouseDown={startDraw}
onMouseMove={draw}
onMouseUp={stopDraw}
onMouseLeave={stopDraw}
onTouchStart={startDraw}
onTouchMove={draw}
onTouchEnd={stopDraw}
/>
<Button size="small" onClick={clearCanvas} className="mt-2">
Clear
</Button>
</Box>
) : (
<Box className="flex flex-col gap-2">
<TextField
value={typedName}
onChange={(event) => setTypedName(event.target.value)}
placeholder="Type your name"
fullWidth
size="small"
/>
{typedName && (
<Typography
variant="h5"
className="rounded border border-dashed border-border px-4 py-6 text-center italic"
>
{typedName}
</Typography>
)}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={!signerName.trim() || (mode === "type" && !typedName.trim())}
>
Save Signature
</Button>
</DialogActions>
</Dialog>
);
return createPortal(dialog, document.body);
}

View file

@ -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<DocVariant, { bg: string; text: string }> = {
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<DocVariant, string> = {
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" ? (
<CheckIcon sx={{ fontSize: 11 }} />
) : variant === "no" ? (
<CloseIcon sx={{ fontSize: 11 }} />
) : (
<RemoveIcon sx={{ fontSize: 11 }} />
);
return (
<span
className="inline-flex items-center gap-1"
style={{
fontSize: 12,
fontWeight: 500,
padding: "3px 8px",
borderRadius: 4,
background: tokens.bg,
color: tokens.text,
}}
>
{showIcon && icon}
{label ?? DOC_LABELS[variant]}
</span>
);
}

View file

@ -0,0 +1,31 @@
import type { StatusVariant } from "./status-badge";
/** Maps API / legacy status strings to Seahaven status variants. */
const STATUS_ALIASES: Record<string, StatusVariant> = {
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;
}

View file

@ -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<StatusVariant, { bg: string; text: string }> = {
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<StatusVariant, string> = {
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 (
<span
className="inline-flex items-center whitespace-nowrap"
style={{
height: 20,
padding: "0 8px",
borderRadius: 4,
fontSize: 12,
fontWeight: 500,
background: tokens.bg,
color: tokens.text,
}}
>
{label ?? STATUS_LABELS[status]}
</span>
);
}
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 <StatusBadge status={variant} label={label} />;
}
return <StatusBadge status="incomplete" label={label} />;
}

View file

@ -0,0 +1,23 @@
import type { TypeVariant } from "./type-badge";
const COMPANY_TYPE_MAP: Record<string, TypeVariant> = {
Vendor: "reactive",
"In-house": "pm",
};
const SERVICE_TYPE_MAP: Record<string, TypeVariant> = {
"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";
}

View file

@ -0,0 +1,39 @@
export type TypeVariant = "pm" | "reactive" | "emergency" | "addon";
const TYPE_TOKENS: Record<TypeVariant, { bg: string; text: string }> = {
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 (
<span
className="inline-flex items-center whitespace-nowrap"
style={{
height: 20,
padding: "0 8px",
borderRadius: 4,
fontSize: 12,
fontWeight: 500,
background: tokens.bg,
color: tokens.text,
}}
>
{label}
</span>
);
}

View file

@ -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 (
<NavLink to={path} end={end} className="no-underline">
{({ isActive }) => (
<ListItemButton
title={collapsed ? label : undefined}
className={cn("mb-1 rounded-md", isActive && !collapsed && "font-semibold")}
sx={{
justifyContent: collapsed ? "center" : "flex-start",
px: collapsed ? 1 : 1.75,
py: 1.25,
color: "var(--color-sidebar-text)",
transition: "background-color var(--duration-fast, 150ms) var(--ease-default, ease)",
"&:hover": {
backgroundColor: "var(--color-sidebar-hover-bg)",
color: "var(--color-sidebar-text-hover)",
},
...(isActive && collapsed
? { backgroundColor: "var(--color-sidebar-hover-bg)" }
: isActive
? {
backgroundColor: "var(--color-sidebar-active-bg)",
color: "var(--color-sidebar-active-text)",
borderLeft: "3px solid var(--primary)",
pl: "13px",
}
: {}),
}}
>
<ListItemIcon
sx={{
minWidth: collapsed ? 0 : 34,
justifyContent: "center",
color: "inherit",
mr: collapsed ? 0 : 0.5,
}}
>
<Icon size={18} />
</ListItemIcon>
{!collapsed && (
<ListItemText
primary={label}
primaryTypographyProps={{
fontSize: "0.875rem",
fontWeight: "inherit",
noWrap: true,
}}
/>
)}
</ListItemButton>
)}
</NavLink>
);
}
type AppSidebarProps = {
collapsed: boolean;
};
export function AppSidebar({ collapsed }: AppSidebarProps) {
const width = collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH;
return (
<Drawer
variant="permanent"
sx={{
width,
flexShrink: 0,
"& .MuiDrawer-paper": {
width,
boxSizing: "border-box",
backgroundColor: "var(--color-sidebar-bg)",
borderRight: "1px solid var(--color-sidebar-border)",
transition: "width var(--duration-normal, 250ms) var(--ease-default, ease)",
overflowX: "hidden",
},
}}
>
<Box
className="flex flex-col"
sx={{
height: "100vh",
px: collapsed ? 1 : 2.5,
py: 2.5,
}}
>
<Box
className={cn("mb-7 flex items-center", collapsed && "justify-center")}
sx={{ minHeight: 40 }}
>
<Box
component="img"
src="/logo.png"
alt="SeaHaven Industries"
sx={{
maxWidth: collapsed ? 32 : 140,
height: "auto",
display: "block",
transition: "max-width var(--duration-normal, 250ms) var(--ease-default, ease)",
mx: collapsed ? "auto" : 0,
}}
/>
</Box>
<List disablePadding className="flex-1 overflow-y-auto overflow-x-hidden">
{navGroups.map((group, groupIndex) => (
<Box key={group.title} component="li" sx={{ listStyle: "none" }}>
{!collapsed && (
<Typography
className="text-label"
sx={{
px: 1.75,
mt: groupIndex === 0 ? 0 : 2.25,
mb: 0.75,
color: "var(--color-sidebar-label)",
}}
>
{group.title}
</Typography>
)}
{collapsed && groupIndex > 0 && (
<Box
sx={{
borderTop: "1px solid var(--color-sidebar-border)",
mt: 1,
pt: 1,
mx: 0.5,
}}
/>
)}
<List disablePadding component="ul">
{group.items.map(({ label, icon, path }) => (
<NavItem
key={path}
label={label}
icon={icon}
path={path}
collapsed={collapsed}
end={path === "/"}
/>
))}
</List>
</Box>
))}
</List>
</Box>
</Drawer>
);
}
export { SIDEBAR_COLLAPSED_WIDTH, SIDEBAR_WIDTH };

View file

@ -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 | HTMLElement>(null);
const [userAnchor, setUserAnchor] = useState<null | HTMLElement>(null);
const handleLogout = () => {
setUserAnchor(null);
logout();
};
const userInitials = user?.name
? user.name
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase()
: "U";
return (
<AppBar
position="sticky"
elevation={0}
sx={{
height: "var(--spacing-topbar)",
background: "var(--color-header-gradient)",
boxShadow: "var(--color-header-shadow)",
color: "var(--color-header-text)",
}}
>
<Toolbar
disableGutters
className="flex justify-between px-4"
sx={{ minHeight: "var(--spacing-topbar) !important" }}
>
<Box className="flex items-center">
<IconButton
onClick={onToggleNav}
aria-label="Toggle navigation"
sx={{
color: "var(--color-header-icon)",
mr: 1.5,
"&:hover": { backgroundColor: "var(--color-header-surface-hover)" },
}}
>
<MenuIcon />
</IconButton>
</Box>
<Box className="flex items-center gap-1">
<IconButton
title="Notifications"
onClick={(event) => setNotifAnchor(event.currentTarget)}
sx={{
color: "var(--color-header-text-muted)",
"&:hover": {
color: "var(--color-header-text)",
backgroundColor: "var(--color-header-chip-hover)",
},
}}
>
<NotificationsNoneOutlinedIcon fontSize="small" />
</IconButton>
<Divider
orientation="vertical"
flexItem
sx={{
mx: 0.5,
borderColor: "var(--color-header-surface)",
alignSelf: "center",
height: 24,
}}
/>
<IconButton
title={user?.name || "User"}
onClick={(event) => setUserAnchor(event.currentTarget)}
sx={{
color: "var(--color-header-text-muted)",
"&:hover": {
color: "var(--color-header-text)",
backgroundColor: "var(--color-header-chip-hover)",
},
}}
>
<PersonOutlineOutlinedIcon fontSize="small" />
</IconButton>
</Box>
</Toolbar>
<Menu
anchorEl={notifAnchor}
open={Boolean(notifAnchor)}
onClose={() => setNotifAnchor(null)}
transformOrigin={{ horizontal: "right", vertical: "top" }}
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
slotProps={{
paper: {
sx: { width: 300, mt: 1, boxShadow: "var(--shadow-lg)" },
},
}}
>
<Box sx={{ px: 2, py: 1.5, borderBottom: "1px solid var(--border)" }}>
<Typography variant="subtitle2">Notifications</Typography>
</Box>
<Box className="flex flex-col items-center gap-2 px-4 py-6 text-muted-foreground">
<CheckCircleOutlineIcon sx={{ fontSize: 28, color: "var(--muted)" }} />
<Typography variant="body2" color="text.secondary">
No new notifications
</Typography>
</Box>
</Menu>
<Menu
anchorEl={userAnchor}
open={Boolean(userAnchor)}
onClose={() => setUserAnchor(null)}
transformOrigin={{ horizontal: "right", vertical: "top" }}
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
slotProps={{
paper: {
sx: { width: 260, mt: 1, boxShadow: "var(--shadow-lg)" },
},
}}
>
<Box className="flex items-center gap-3 px-4 py-3">
<Avatar
sx={{
width: 40,
height: 40,
bgcolor: "var(--accent)",
fontSize: "0.875rem",
fontWeight: 600,
border: "2px solid var(--color-header-avatar-ring)",
}}
>
{userInitials}
</Avatar>
<Box>
<Typography variant="subtitle2">{user?.name || "User"}</Typography>
<Typography variant="caption" color="text.secondary">
{user?.email || ""}
</Typography>
</Box>
</Box>
<Divider />
<MenuItem
onClick={() => {
setUserAnchor(null);
navigate("/settings/dropdowns");
}}
>
<ListItemIcon>
<SettingsOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Settings</ListItemText>
</MenuItem>
<MenuItem onClick={handleLogout} sx={{ color: "error.main" }}>
<ListItemIcon>
<LogoutOutlinedIcon fontSize="small" color="error" />
</ListItemIcon>
<ListItemText>Logout</ListItemText>
</MenuItem>
</Menu>
</AppBar>
);
}

View file

@ -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 (
<Chip
size={size}
{...props}
className={cn("font-medium", className)}
sx={{
borderRadius: "var(--radius-sm)",
fontSize: "0.75rem",
...props.sx,
}}
/>
);
}

View file

@ -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<ButtonProps, "variant" | "color" | "size"> & {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
loadingText?: string;
};
const variantMap: Record<
ButtonVariant,
Pick<ButtonProps, "variant" | "color"> & { 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<ButtonSize, ButtonProps["sx"]> = {
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 (
<MuiButton
{...mapped}
disabled={disabled || loading}
sx={[sizeSx[size], ...(Array.isArray(sx) ? sx : sx ? [sx] : [])]}
className={cn(mapped.className, className)}
{...props}
>
{loading ? (
<>
<CircularProgress size={16} color="inherit" sx={{ mr: 1 }} />
{loadingText}
</>
) : (
children
)}
</MuiButton>
);
}

View file

@ -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 (
<Box
className={cn(
"flex flex-col items-center justify-center gap-3 rounded-lg border border-border bg-card px-6 py-12 text-center",
className,
)}
>
{icon && <Box className="text-muted-foreground">{icon}</Box>}
<Typography variant="subtitle1" className="text-foreground">
{title}
</Typography>
{description && (
<Typography variant="body2" color="text.secondary" className="max-w-sm">
{description}
</Typography>
)}
{action && <Box className="mt-2">{action}</Box>}
</Box>
);
}

View file

@ -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 (
<div className={cn("mt-0.5 flex items-center gap-1.5", className)} {...props}>
<ErrorOutlineIcon fontSize="small" color="error" />
<Typography
variant="caption"
color="error"
sx={{ fontFamily: "var(--font-sans)", fontSize: "var(--text-sm)" }}
>
{message}
</Typography>
</div>
);
}

View file

@ -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<FormFieldAppearance, string> = {
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<FormFieldAppearance, string> = {
default: "text-label static mb-1 text-foreground normal-case",
auth: "static text-foreground normal-case",
};
const appearanceLabelSx: Record<FormFieldAppearance, object | undefined> = {
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 (
<FormControl
error={error}
required={required}
fullWidth
className={cn(appearanceClassName[appearance], className)}
{...props}
>
<FormLabel
htmlFor={htmlFor}
className={appearanceLabelClassName[appearance]}
sx={appearanceLabelSx[appearance]}
>
{label}
</FormLabel>
{children}
{helperText && <FormHelperText>{helperText}</FormHelperText>}
</FormControl>
);
}

View file

@ -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 (
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
className={cn("mb-4", className)}
>
<Stack spacing={0.5}>
{eyebrow && (
<Typography variant="overline" className="text-muted-foreground">
{eyebrow}
</Typography>
)}
<Typography variant="h5" component="h1" className="text-foreground">
{title}
</Typography>
{subtitle && (
<Typography variant="body2" color="text.secondary">
{subtitle}
</Typography>
)}
</Stack>
{actions && (
<Stack direction="row" spacing={1} alignItems="center">
{actions}
</Stack>
)}
</Stack>
);
}

View file

@ -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<UiTextFieldProps, "type"> & {
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 (
<TextField
type={showPassword ? "text" : "password"}
sx={[passwordFieldSx, ...(Array.isArray(sx) ? sx : sx ? [sx] : [])]}
slotProps={{
...slotProps,
input: {
...slotProps?.input,
endAdornment: (
<InputAdornment position="end">
<IconButton
type="button"
aria-label={showPassword ? "Hide password" : "Show password"}
onClick={toggleVisibility}
size="small"
className="text-muted-foreground"
>
{showPassword ? (
<VisibilityOffOutlinedIcon className="h-[16px]! w-[16px]!" />
) : (
<VisibilityOutlinedIcon className="h-[16px]! w-[16px]!" />
)}
</IconButton>
</InputAdornment>
),
},
}}
{...props}
/>
);
}

View file

@ -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 (
<MuiTextField
fullWidth={fullWidth}
hiddenLabel={hiddenLabel}
size={size}
sx={[textFieldSx, ...(Array.isArray(sx) ? sx : sx ? [sx] : [])]}
className={cn(className)}
{...props}
/>
);
}