mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 04:12:09 +00:00
feat: add shared UI, layout and domain badge components
This commit is contained in:
parent
49fbc98dd0
commit
ddc174c204
22 changed files with 1712 additions and 0 deletions
65
src/components/common/brand-lockup.tsx
Normal file
65
src/components/common/brand-lockup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/components/common/calendar/calendar-action-bar.tsx
Normal file
124
src/components/common/calendar/calendar-action-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
src/components/common/calendar/calendar-utils.ts
Normal file
151
src/components/common/calendar/calendar-utils.ts
Normal 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;
|
||||
56
src/components/common/calendar/event-calendar.tsx
Normal file
56
src/components/common/calendar/event-calendar.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
24
src/components/common/seahaven-mark.tsx
Normal file
24
src/components/common/seahaven-mark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/common/settings-nav.tsx
Normal file
29
src/components/common/settings-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
214
src/components/common/signature-capture.tsx
Normal file
214
src/components/common/signature-capture.tsx
Normal 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);
|
||||
}
|
||||
53
src/components/domain/doc-badge.tsx
Normal file
53
src/components/domain/doc-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/domain/status-badge-utils.ts
Normal file
31
src/components/domain/status-badge-utils.ts
Normal 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;
|
||||
}
|
||||
84
src/components/domain/status-badge.tsx
Normal file
84
src/components/domain/status-badge.tsx
Normal 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} />;
|
||||
}
|
||||
23
src/components/domain/type-badge-utils.ts
Normal file
23
src/components/domain/type-badge-utils.ts
Normal 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";
|
||||
}
|
||||
39
src/components/domain/type-badge.tsx
Normal file
39
src/components/domain/type-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
240
src/components/layout/app-sidebar.tsx
Normal file
240
src/components/layout/app-sidebar.tsx
Normal 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 };
|
||||
198
src/components/layout/app-topbar.tsx
Normal file
198
src/components/layout/app-topbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/components/ui/badge.tsx
Normal file
21
src/components/ui/badge.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
68
src/components/ui/button.tsx
Normal file
68
src/components/ui/button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/components/ui/empty-state.tsx
Normal file
34
src/components/ui/empty-state.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/components/ui/form-error-message.tsx
Normal file
24
src/components/ui/form-error-message.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/components/ui/form-field.tsx
Normal file
70
src/components/ui/form-field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/components/ui/page-header.tsx
Normal file
45
src/components/ui/page-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
src/components/ui/password-field.tsx
Normal file
68
src/components/ui/password-field.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
51
src/components/ui/text-field.tsx
Normal file
51
src/components/ui/text-field.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue