diff --git a/public/seahaven-logo.svg b/public/seahaven-logo.svg new file mode 100644 index 00000000..1bcd9280 --- /dev/null +++ b/public/seahaven-logo.svg @@ -0,0 +1,108 @@ + + + +Seahaven Industries + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/api/api.ts b/src/api/api.ts index 7a72c711..162231cc 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -1,10 +1,11 @@ -import ky, { type KyInstance, type Options } from "ky"; +import ky, { type HTTPError, type KyInstance, type Options } from "ky"; import { env } from "@/lib/env"; import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url"; import { API_PATHS } from "@/api/api-paths"; -import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error"; +import { createApiErrorFromResponse, mapHttpStatusToMessage } from "@/api/api-error"; import { queryKeys } from "@/infra/query-key/query-key"; import queryClient from "@/lib/query/query-client"; +import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass"; import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage"; function clearSessionAndRedirectToLogin(): void { @@ -21,24 +22,37 @@ function isAuthLoginRequest(request: Request): boolean { } export const api: KyInstance = ky.create({ - prefix: resolveApiPrefixUrl(env.apiUrl), + prefixUrl: resolveApiPrefixUrl(env.apiUrl), headers: { Accept: "application/json", "Content-Type": "application/json", }, hooks: { beforeRequest: [ - ({ request }) => { + (request) => { const token = getAccessToken(); if (token) { request.headers.set("Authorization", token); } }, ], - beforeError: [({ error }) => normalizeApiRequestError(error)], + beforeError: [ + async (error: HTTPError) => { + const { response } = error; + + if (!response) { + error.message = "No response from server. Please check your connection."; + return error; + } + + const apiError = await createApiErrorFromResponse(response); + error.message = apiError.message; + return error; + }, + ], afterResponse: [ - ({ request, response }) => { - if (response.status !== 401 || isAuthLoginRequest(request)) { + async (request, _options, response) => { + if (response.status !== 401 || isAuthLoginRequest(request) || isAuthBypassEnabled()) { return response; } diff --git a/src/app/(protected)/_layout.tsx b/src/app/(protected)/_layout.tsx index b54beafc..6d6655fa 100644 --- a/src/app/(protected)/_layout.tsx +++ b/src/app/(protected)/_layout.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Outlet } from "react-router"; +import { Outlet } from "react-router-dom"; import { Box } from "@mui/material"; import { ProtectedRoute } from "@/components/auth/protected-route"; @@ -24,7 +24,7 @@ export default function AdminShellLayout() { diff --git a/src/app/(protected)/vendor-pos/[id].tsx b/src/app/(protected)/vendor-pos/[id].tsx index 04914e0a..1fd959b1 100644 --- a/src/app/(protected)/vendor-pos/[id].tsx +++ b/src/app/(protected)/vendor-pos/[id].tsx @@ -1,5 +1,5 @@ import { useState, type ReactNode } from "react"; -import { useNavigate, useParams } from "react-router"; +import { useNavigate, useParams } from "react-router-dom"; import { Alert, Box, @@ -22,7 +22,7 @@ import { } from "@mui/material"; import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions"; import { useVendorPoDetail } from "@/domain/vendor-pos/use-cases/use-vendor-po-detail"; -import type { UpliftRequest } from "@/domain/work-orders/types/work-order"; +import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; import { formatDateTime, toDateInputValue } from "@/lib/time-utils"; function formatMoney(value: number | null | undefined): string { @@ -33,13 +33,7 @@ function formatMoney(value: number | null | undefined): string { function InfoRow({ label, children }: { label: string; children: ReactNode }) { return ( - + {label} @@ -113,14 +107,7 @@ export default function VendorPoViewPage() { return ( - + @@ -128,47 +115,27 @@ export default function VendorPoViewPage() { Print + {dispatch.poNumber || "(no PO #)"} - - + + Dispatch {dispatch.dispatchNumber || "—"} {vendor?.companyName && ( - + · {vendor.companyName} )} {dispatch.nteAmount != null && ( - + · NTE {formatMoney(dispatch.nteAmount)} )} + @@ -186,13 +153,7 @@ export default function VendorPoViewPage() { {vendor.tradeSpecialties} ) : ( - - No vendor attached. - + No vendor attached. )} @@ -214,12 +175,7 @@ export default function VendorPoViewPage() { {dispatch.description && ( - + Description @@ -255,13 +211,7 @@ export default function VendorPoViewPage() { Linked Work Orders ({dispatch.workOrders.length}) {dispatch.workOrders.length === 0 ? ( - - No work orders linked. - + No work orders linked. ) : ( @@ -301,33 +251,19 @@ export default function VendorPoViewPage() { NTE Uplift History ({dispatch.upliftRequests.length}) {dispatch.upliftRequests.length === 0 ? ( - - No uplift requests on this PO. - + No uplift requests on this PO. ) : ( {dispatch.upliftRequests.map((u) => ( - + {formatMoney(u.currentNTE)} → {formatMoney(u.requestedNTE)} (Δ {formatMoney(u.delta)}, Tier {u.requiredTier}) @@ -371,6 +307,7 @@ export default function VendorPoViewPage() { )} + setUpliftAction(null)}> {upliftAction?.type === "approve" ? "Approve Uplift" : "Deny Uplift"} diff --git a/src/components/common/comment-timeline-mappers.ts b/src/components/common/comment-timeline-mappers.ts new file mode 100644 index 00000000..aad27c4b --- /dev/null +++ b/src/components/common/comment-timeline-mappers.ts @@ -0,0 +1,22 @@ +import type { CommentTimelineItem } from "./comment-timeline"; + +type CommentSource = { + id: string | number; + userName?: string; + commenter?: string | null; + createdDate?: string; + commenttext: string; +}; + +export function mapWorkOrderCommentsToTimeline(comments: CommentSource[]): CommentTimelineItem[] { + return comments.map((comment) => ({ + id: comment.id, + author: comment.userName || comment.commenter || "System", + createdAt: comment.createdDate, + body: comment.commenttext, + })); +} + +export function mapDispatchCommentsToTimeline(comments: CommentSource[]): CommentTimelineItem[] { + return mapWorkOrderCommentsToTimeline(comments); +} diff --git a/src/components/common/comment-timeline.tsx b/src/components/common/comment-timeline.tsx new file mode 100644 index 00000000..01ecf292 --- /dev/null +++ b/src/components/common/comment-timeline.tsx @@ -0,0 +1,54 @@ +import { useMemo } from "react"; +import { Box, Stack, Typography } from "@mui/material"; +import { formatDateTime } from "@/lib/time-utils"; + +export type CommentTimelineItem = { + id: string | number; + author: string; + createdAt?: string; + body: string; +}; + +type CommentTimelineProps = { + items: CommentTimelineItem[]; + emptyMessage?: string; + maxHeightClass?: string; +}; + +export function CommentTimeline({ + items, + emptyMessage = "No comments yet.", + maxHeightClass = "max-h-72", +}: CommentTimelineProps) { + const sorted = useMemo( + () => + [...items].sort((a, b) => { + const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return aTime - bTime; + }), + [items], + ); + + if (!sorted.length) { + return {emptyMessage}; + } + + return ( + + {sorted.map((item) => ( + + + + {item.author} + + + {item.createdAt ? formatDateTime(item.createdAt) : ""} + + + {item.body} + + ))} + + ); +} diff --git a/src/components/common/detail-field.tsx b/src/components/common/detail-field.tsx new file mode 100644 index 00000000..0b0f2fdf --- /dev/null +++ b/src/components/common/detail-field.tsx @@ -0,0 +1,36 @@ +import { Box, Typography } from "@mui/material"; + +type DetailFieldHighlight = "green" | "yellow" | "red"; + +type DetailFieldProps = { + label: string; + value: string; + className?: string; + highlight?: DetailFieldHighlight | ""; +}; + +export function DetailField({ label, value, className, highlight }: DetailFieldProps) { + const colorClass = + highlight === "green" + ? "text-green-600" + : highlight === "yellow" + ? "text-yellow-600" + : highlight === "red" + ? "text-red-600" + : undefined; + + return ( + + + {label} + + + {value} + + + ); +} + +export function DetailFieldGrid({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/src/components/domain/status-badge.tsx b/src/components/domain/status-badge.tsx index d7a0aac8..a9b181e3 100644 --- a/src/components/domain/status-badge.tsx +++ b/src/components/domain/status-badge.tsx @@ -51,13 +51,8 @@ export function StatusBadge({ status, label }: StatusBadgeProps) { return ( = { 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)" }, + overdue: { bg: "var(--type-overdue-bg)", text: "var(--type-overdue-text)" }, }; import { resolveTypeVariant } from "./type-badge-utils"; diff --git a/src/components/layout/app-topbar.tsx b/src/components/layout/app-topbar.tsx index db4eb586..cf679631 100644 --- a/src/components/layout/app-topbar.tsx +++ b/src/components/layout/app-topbar.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useNavigate } from "react-router"; +import { useNavigate } from "react-router-dom"; import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined"; import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined"; import { @@ -52,7 +52,7 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) { sx={{ height: "var(--spacing-topbar)", background: "var(--color-header-gradient)", - boxShadow: "var(--color-header-shadow)", + boxShadow: "none", color: "var(--color-header-text)", borderRadius: 0, }} @@ -169,6 +169,7 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) { /> + - + {user?.fullname || "User"} - + {user?.email || ""} diff --git a/src/components/ui/applied-filter-chip.tsx b/src/components/ui/applied-filter-chip.tsx new file mode 100644 index 00000000..6ae16808 --- /dev/null +++ b/src/components/ui/applied-filter-chip.tsx @@ -0,0 +1,51 @@ +import { X } from "lucide-react"; + +import { CountBadge } from "@/components/ui/count-badge"; +import { cn } from "@/lib/utils"; + +export type AppliedFilterChipProps = { + group: string; + value?: string; + count?: number; + accent?: boolean; + onDismiss: () => void; + dismissLabel?: string; +}; + +export function AppliedFilterChip({ + group, + value, + count, + accent, + onDismiss, + dismissLabel = "Remove filter", +}: AppliedFilterChipProps) { + return ( + + + {group} + + {count != null ? ( + + ) : ( + + {value} + + )} + + + ); +} diff --git a/src/components/ui/applied-filters-bar.tsx b/src/components/ui/applied-filters-bar.tsx new file mode 100644 index 00000000..145159a5 --- /dev/null +++ b/src/components/ui/applied-filters-bar.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; +import { Filter } from "lucide-react"; + +type AppliedFiltersBarProps = { + countLabel: string; + children: ReactNode; + onClearAll: () => void; + clearAllLabel?: string; + filteredByLabel?: string; +}; + +export function AppliedFiltersBar({ + countLabel, + children, + onClearAll, + clearAllLabel = "Clear all", + filteredByLabel = "· filtered by", +}: AppliedFiltersBarProps) { + return ( +
+
+ + {countLabel} + {filteredByLabel} + {children} + +
+
+ ); +} diff --git a/src/components/ui/checkbox-indicator.tsx b/src/components/ui/checkbox-indicator.tsx new file mode 100644 index 00000000..625e7678 --- /dev/null +++ b/src/components/ui/checkbox-indicator.tsx @@ -0,0 +1,33 @@ +import { Check } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +type CheckboxIndicatorSize = "sm" | "md"; + +const sizeClasses: Record = { + sm: { box: "h-3.5 w-3.5", icon: 9 }, + md: { box: "size-4", icon: 11 }, +}; + +export type CheckboxIndicatorProps = { + checked: boolean; + size?: CheckboxIndicatorSize; + className?: string; +}; + +export function CheckboxIndicator({ checked, size = "md", className }: CheckboxIndicatorProps) { + const { box, icon } = sizeClasses[size]; + + return ( + + {checked && } + + ); +} diff --git a/src/components/ui/checkbox-list-item.tsx b/src/components/ui/checkbox-list-item.tsx new file mode 100644 index 00000000..71ffbd61 --- /dev/null +++ b/src/components/ui/checkbox-list-item.tsx @@ -0,0 +1,38 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +import { CheckboxIndicator } from "@/components/ui/checkbox-indicator"; +import { cn } from "@/lib/utils"; + +export type CheckboxListItemProps = Omit, "children"> & { + selected: boolean; + label: ReactNode; + left?: ReactNode; +}; + +export function CheckboxListItem({ + selected, + label, + left, + className, + type = "button", + disabled, + ...props +}: CheckboxListItemProps) { + return ( + + ); +} diff --git a/src/components/ui/checkbox-menu-item.tsx b/src/components/ui/checkbox-menu-item.tsx new file mode 100644 index 00000000..473bde76 --- /dev/null +++ b/src/components/ui/checkbox-menu-item.tsx @@ -0,0 +1,50 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +import { CheckboxIndicator } from "@/components/ui/checkbox-indicator"; +import { cn } from "@/lib/utils"; + +export type CheckboxMenuItemProps = Omit, "children"> & { + checked: boolean; + label: ReactNode; + left?: ReactNode; + right?: ReactNode; + labelClassName?: string; +}; + +export function CheckboxMenuItem({ + checked, + label, + left, + right, + labelClassName, + className, + type = "button", + disabled, + ...props +}: CheckboxMenuItemProps) { + return ( + + ); +} diff --git a/src/components/ui/collapsible-section.tsx b/src/components/ui/collapsible-section.tsx new file mode 100644 index 00000000..273fc9cc --- /dev/null +++ b/src/components/ui/collapsible-section.tsx @@ -0,0 +1,50 @@ +import { useState, type ElementType, type ReactNode } from "react"; +import { ChevronDown, ChevronUp } from "lucide-react"; + +import { CountBadge } from "@/components/ui/count-badge"; +import { cn } from "@/lib/utils"; + +export type CollapsibleSectionProps = { + icon?: ElementType; + label: string; + count?: number; + defaultOpen?: boolean; + children: ReactNode; + className?: string; +}; + +export function CollapsibleSection({ + icon: Icon, + label, + count = 0, + defaultOpen = false, + children, + className, +}: CollapsibleSectionProps) { + const [open, setOpen] = useState(defaultOpen); + + return ( +
+ + {open && children} +
+ ); +} diff --git a/src/components/ui/count-badge.tsx b/src/components/ui/count-badge.tsx new file mode 100644 index 00000000..c66140da --- /dev/null +++ b/src/components/ui/count-badge.tsx @@ -0,0 +1,23 @@ +import { cn } from "@/lib/utils"; + +export type CountBadgeProps = { + count: number; + className?: string; +}; + +export function CountBadge({ count, className }: CountBadgeProps) { + if (count <= 0) { + return null; + } + + return ( + + {count} + + ); +} diff --git a/src/components/ui/date-input.tsx b/src/components/ui/date-input.tsx new file mode 100644 index 00000000..a62d478e --- /dev/null +++ b/src/components/ui/date-input.tsx @@ -0,0 +1,40 @@ +import type { InputHTMLAttributes } from "react"; + +import { cn } from "@/lib/utils"; + +const dateInputClasses = + "w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-[13px] text-foreground focus:outline-none"; + +export type DateInputProps = Omit, "type" | "onChange"> & { + onChange?: (value: string) => void; +}; + +export function DateInput({ className, onChange, disabled, ...props }: DateInputProps) { + return ( + onChange?.(event.target.value)} + {...props} + /> + ); +} + +export type DateFieldProps = DateInputProps & { + label: string; + fieldClassName?: string; +}; + +export function DateField({ label, fieldClassName, id, ...inputProps }: DateFieldProps) { + const inputId = id ?? label.toLowerCase().replace(/\s+/g, "-"); + + return ( +
+ + +
+ ); +} diff --git a/src/components/ui/dialog-blur-backdrop.ts b/src/components/ui/dialog-blur-backdrop.ts new file mode 100644 index 00000000..3842d5b1 --- /dev/null +++ b/src/components/ui/dialog-blur-backdrop.ts @@ -0,0 +1,12 @@ +import type { SxProps, Theme } from "@mui/material"; + +/** Light blur overlay — matches design DialogOverlay (bg-black/10 + backdrop-blur). */ +export const DIALOG_BLUR_BACKDROP_SX: SxProps = { + backgroundColor: "rgba(0, 0, 0, 0.1)", + backdropFilter: "blur(4px)", + WebkitBackdropFilter: "blur(4px)", +}; + +export const dialogBlurBackdropSlotProps = { + backdrop: { sx: DIALOG_BLUR_BACKDROP_SX }, +} as const; diff --git a/src/components/ui/dot-toggle-pill.tsx b/src/components/ui/dot-toggle-pill.tsx new file mode 100644 index 00000000..cd68e7c0 --- /dev/null +++ b/src/components/ui/dot-toggle-pill.tsx @@ -0,0 +1,50 @@ +import type { ButtonHTMLAttributes, CSSProperties } from "react"; + +import { cn } from "@/lib/utils"; + +export type DotTogglePillProps = Omit, "children"> & { + label: string; + dotColor: string; + selected: boolean; + selectedBg?: string; + selectedText?: string; +}; + +export function DotTogglePill({ + label, + dotColor, + selected, + selectedBg, + selectedText, + className, + type = "button", + disabled, + style, + ...props +}: DotTogglePillProps) { + const selectedStyle: CSSProperties | undefined = + selected && selectedBg ? { background: selectedBg } : undefined; + + return ( + + ); +} diff --git a/src/components/ui/filter-popover.tsx b/src/components/ui/filter-popover.tsx new file mode 100644 index 00000000..59824344 --- /dev/null +++ b/src/components/ui/filter-popover.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from "react"; +import { Popover } from "@mui/material"; + +type FilterPopoverProps = { + open: boolean; + anchorEl: HTMLElement | null; + onClose: () => void; + activeCount: number; + onClear?: () => void; + children: ReactNode; +}; + +export function FilterPopover({ + open, + anchorEl, + onClose, + activeCount, + onClear, + children, +}: FilterPopoverProps) { + return ( + +
+
+ Filters + {activeCount > 0 && onClear && ( + + )} +
+ {children} +
+
+ ); +} diff --git a/src/components/ui/icon-close-button.tsx b/src/components/ui/icon-close-button.tsx new file mode 100644 index 00000000..a7874df6 --- /dev/null +++ b/src/components/ui/icon-close-button.tsx @@ -0,0 +1,35 @@ +import type { ButtonHTMLAttributes } from "react"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +export type IconCloseButtonProps = Omit, "children"> & { + iconSize?: number; + label?: string; +}; + +export function IconCloseButton({ + iconSize = 17, + label = "Close", + className, + type = "button", + disabled, + "aria-label": ariaLabel, + ...props +}: IconCloseButtonProps) { + return ( + + ); +} diff --git a/src/components/ui/menu-anchor-button.tsx b/src/components/ui/menu-anchor-button.tsx new file mode 100644 index 00000000..e6270cc1 --- /dev/null +++ b/src/components/ui/menu-anchor-button.tsx @@ -0,0 +1,32 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +type MenuAnchorButtonProps = Omit, "onClick" | "type"> & { + onAnchor: (element: HTMLElement) => void; + children: ReactNode; + open?: boolean; +}; + +export function MenuAnchorButton({ + onAnchor, + children, + className, + open, + disabled, + ...props +}: MenuAnchorButtonProps) { + return ( + + ); +} diff --git a/src/components/ui/nav-arrow-button.tsx b/src/components/ui/nav-arrow-button.tsx new file mode 100644 index 00000000..6441e4c2 --- /dev/null +++ b/src/components/ui/nav-arrow-button.tsx @@ -0,0 +1,30 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +export type NavArrowButtonProps = Omit, "children"> & { + children: ReactNode; +}; + +export function NavArrowButton({ + children, + className, + disabled, + type = "button", + ...props +}: NavArrowButtonProps) { + return ( + + ); +} diff --git a/src/components/ui/nav-pill-button.tsx b/src/components/ui/nav-pill-button.tsx new file mode 100644 index 00000000..e43b7e94 --- /dev/null +++ b/src/components/ui/nav-pill-button.tsx @@ -0,0 +1,34 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +export type NavPillButtonProps = Omit, "children"> & { + active?: boolean; + children: ReactNode; +}; + +export function NavPillButton({ + active = false, + children, + className, + disabled, + type = "button", + ...props +}: NavPillButtonProps) { + return ( + + ); +} diff --git a/src/components/ui/page-header.tsx b/src/components/ui/page-header.tsx index d6fd0224..885a4138 100644 --- a/src/components/ui/page-header.tsx +++ b/src/components/ui/page-header.tsx @@ -9,47 +9,51 @@ type PageHeaderProps = { subtitle?: string; actions?: ReactNode; className?: string; + /** White surface bar (list page headers). */ + surface?: boolean; }; -export function PageHeader({ eyebrow, title, subtitle, actions, className }: PageHeaderProps) { +export function PageHeader({ + eyebrow, + title, + subtitle, + actions, + className, + surface = false, +}: PageHeaderProps) { return ( - + {eyebrow && ( - +

{eyebrow} - +

)} - +

{title} - +

{subtitle && ( - + {subtitle} )}
{actions && ( - + {actions} )} diff --git a/src/components/ui/resource-list-table.tsx b/src/components/ui/resource-list-table.tsx new file mode 100644 index 00000000..56b5cce1 --- /dev/null +++ b/src/components/ui/resource-list-table.tsx @@ -0,0 +1,95 @@ +import type { ReactNode } from "react"; +import { + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from "@mui/material"; +import { TableEmptyRow, TableLoadingRow } from "@/components/ui/table-state-rows"; +import type { SortDirection } from "@/domain/work-orders/types/work-order-list"; + +export type ResourceListColumn = { + id: string; + header: ReactNode; + render: (row: T) => ReactNode; + sortable?: boolean; + headerClassName?: string; + cellClassName?: string; + align?: "left" | "center" | "right"; +}; + +type ResourceListTableProps = { + columns: ResourceListColumn[]; + rows: T[]; + rowKey: (row: T) => string; + isLoading: boolean; + emptyMessage?: string; + sort?: { column: string; direction: SortDirection }; + onSort?: (column: string) => void; + size?: "small" | "medium"; +}; + +function sortIndicator(direction: SortDirection): string { + if (direction === "desc") { + return " ↓"; + } + if (direction === "asc") { + return " ↑"; + } + return ""; +} + +export function ResourceListTable({ + columns, + rows, + rowKey, + isLoading, + emptyMessage, + sort, + onSort, + size = "small", +}: ResourceListTableProps) { + const colSpan = columns.length; + + return ( + +
+ + + {columns.map((column) => ( + onSort(column.id) : undefined} + > + {column.header} + {column.sortable && sort?.column === column.id ? sortIndicator(sort.direction) : ""} + + ))} + + + + {isLoading ? ( + + ) : rows.length === 0 ? ( + + ) : ( + rows.map((row) => ( + + {columns.map((column) => ( + + {column.render(row)} + + ))} + + )) + )} + +
+ + ); +} diff --git a/src/components/ui/search-input.tsx b/src/components/ui/search-input.tsx new file mode 100644 index 00000000..b1d6241e --- /dev/null +++ b/src/components/ui/search-input.tsx @@ -0,0 +1,53 @@ +import type { InputHTMLAttributes } from "react"; +import { Search } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +type SearchInputProps = Omit< + InputHTMLAttributes, + "value" | "onChange" | "type" +> & { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; + inputClassName?: string; + fullWidth?: boolean; + "aria-label"?: string; +}; + +export function SearchInput({ + value, + onChange, + placeholder = "Search…", + className, + inputClassName, + fullWidth = false, + "aria-label": ariaLabel = "Search", + ...props +}: SearchInputProps) { + return ( +
+ + onChange(event.currentTarget.value)} + placeholder={placeholder} + aria-label={ariaLabel} + className={cn( + "box-border rounded-md border border-border bg-background py-1.5 pl-8 text-foreground focus:outline-none", + fullWidth + ? "w-full pr-2.5 text-[13px]" + : "w-56 pr-7 font-sans text-[12px] placeholder:text-muted-foreground placeholder:opacity-100 hover:border-border-hover focus:border-border", + inputClassName, + )} + {...props} + /> +
+ ); +} diff --git a/src/components/ui/segment-control.tsx b/src/components/ui/segment-control.tsx new file mode 100644 index 00000000..fbc23757 --- /dev/null +++ b/src/components/ui/segment-control.tsx @@ -0,0 +1,49 @@ +import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +export type SegmentGroupProps = HTMLAttributes & { + children: ReactNode; +}; + +export function SegmentGroup({ children, className, ...props }: SegmentGroupProps) { + return ( +
+ {children} +
+ ); +} + +export type SegmentButtonProps = Omit, "children"> & { + active?: boolean; + children: ReactNode; +}; + +export function SegmentButton({ + active = false, + children, + className, + type = "button", + disabled, + ...props +}: SegmentButtonProps) { + return ( + + ); +} diff --git a/src/components/ui/select-all-action.tsx b/src/components/ui/select-all-action.tsx new file mode 100644 index 00000000..016b4a16 --- /dev/null +++ b/src/components/ui/select-all-action.tsx @@ -0,0 +1,31 @@ +import type { ButtonHTMLAttributes } from "react"; + +import { cn } from "@/lib/utils"; + +export type SelectAllActionProps = Omit, "children"> & { + anySelected: boolean; + selectAllLabel?: string; + clearAllLabel?: string; +}; + +export function SelectAllAction({ + anySelected, + selectAllLabel = "Select all", + clearAllLabel = "Clear all", + className, + type = "button", + ...props +}: SelectAllActionProps) { + return ( + + ); +} diff --git a/src/components/ui/side-sheet.tsx b/src/components/ui/side-sheet.tsx new file mode 100644 index 00000000..d49532eb --- /dev/null +++ b/src/components/ui/side-sheet.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import { Drawer } from "@mui/material"; +import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop"; +import { cn } from "@/lib/utils"; + +type SideSheetProps = { + open: boolean; + onClose: () => void; + children: ReactNode; + widthClass?: string; + "aria-label"?: string; +}; + +export function SideSheet({ + open, + onClose, + children, + widthClass = "w-full max-w-sm", + "aria-label": ariaLabel = "Side panel", +}: SideSheetProps) { + return ( + + {children} + + ); +} diff --git a/src/components/ui/status-chip-filter.tsx b/src/components/ui/status-chip-filter.tsx new file mode 100644 index 00000000..8a3c0a07 --- /dev/null +++ b/src/components/ui/status-chip-filter.tsx @@ -0,0 +1,42 @@ +import { Chip, Stack } from "@mui/material"; + +type StatusChipFilterProps = { + options: readonly string[]; + value: string; + onChange: (status: string) => void; + label?: string; +}; + +export function StatusChipFilter({ + options, + value, + onChange, + label = "Status", +}: StatusChipFilterProps) { + return ( +
+ {label} + + onChange("")} + /> + {options.map((status) => ( + onChange(status)} + /> + ))} + +
+ ); +} diff --git a/src/components/ui/table-state-rows.tsx b/src/components/ui/table-state-rows.tsx new file mode 100644 index 00000000..95becef7 --- /dev/null +++ b/src/components/ui/table-state-rows.tsx @@ -0,0 +1,31 @@ +import { CircularProgress, TableCell, TableRow, Typography } from "@mui/material"; + +type TableLoadingRowProps = { + colSpan: number; + size?: number; +}; + +export function TableLoadingRow({ colSpan, size = 28 }: TableLoadingRowProps) { + return ( + + + + + + ); +} + +type TableEmptyRowProps = { + colSpan: number; + message?: string; +}; + +export function TableEmptyRow({ colSpan, message = "No items found" }: TableEmptyRowProps) { + return ( + + + {message} + + + ); +} diff --git a/src/components/ui/toggle-card.tsx b/src/components/ui/toggle-card.tsx new file mode 100644 index 00000000..d3fb29ef --- /dev/null +++ b/src/components/ui/toggle-card.tsx @@ -0,0 +1,40 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +export type ToggleCardProps = Omit, "children"> & { + selected: boolean; + title: ReactNode; + description?: ReactNode; +}; + +export function ToggleCard({ + selected, + title, + description, + className, + type = "button", + disabled, + ...props +}: ToggleCardProps) { + return ( + + ); +} diff --git a/src/components/ui/toggle-pill.tsx b/src/components/ui/toggle-pill.tsx new file mode 100644 index 00000000..16295c45 --- /dev/null +++ b/src/components/ui/toggle-pill.tsx @@ -0,0 +1,35 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +export type TogglePillProps = Omit, "children"> & { + selected: boolean; + children: ReactNode; +}; + +export function TogglePill({ + selected, + children, + className, + type = "button", + disabled, + ...props +}: TogglePillProps) { + return ( + + ); +} diff --git a/src/components/ui/toggle-segment.tsx b/src/components/ui/toggle-segment.tsx new file mode 100644 index 00000000..2a2e2e9f --- /dev/null +++ b/src/components/ui/toggle-segment.tsx @@ -0,0 +1,35 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +export type ToggleSegmentProps = Omit, "children"> & { + selected: boolean; + children: ReactNode; +}; + +export function ToggleSegment({ + selected, + children, + className, + type = "button", + disabled, + ...props +}: ToggleSegmentProps) { + return ( + + ); +} diff --git a/src/components/ui/toolbar-button.tsx b/src/components/ui/toolbar-button.tsx new file mode 100644 index 00000000..2c4ac157 --- /dev/null +++ b/src/components/ui/toolbar-button.tsx @@ -0,0 +1,55 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +type ToolbarButtonVariant = "outline" | "primary"; + +export type ToolbarButtonProps = Omit, "children"> & { + variant?: ToolbarButtonVariant; + icon?: ReactNode; + children: ReactNode; + badge?: number; +}; + +const baseClasses = + "inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-md text-[12px] font-medium whitespace-nowrap [&_svg]:pointer-events-none [&_svg]:shrink-0"; + +const variantClasses: Record = { + outline: + "relative border border-border bg-background px-3 text-foreground transition-colors hover:bg-muted", + primary: + "justify-center border border-transparent bg-clip-padding px-2.5 bg-primary text-primary-foreground transition-all outline-none select-none hover:bg-primary/80 active:translate-y-px", +}; + +export function ToolbarButton({ + variant = "outline", + icon, + children, + badge, + className, + disabled, + type = "button", + ...props +}: ToolbarButtonProps) { + return ( + + ); +} diff --git a/src/domain/auth/api/auth-api.ts b/src/domain/auth/api/auth-api.ts index 279baaa7..bdbafe16 100644 --- a/src/domain/auth/api/auth-api.ts +++ b/src/domain/auth/api/auth-api.ts @@ -6,6 +6,11 @@ import { API_PATHS } from "@/api/api-paths"; import { apiPost } from "@/api/api"; import { handleApiResponse } from "@/api/handle-api-response"; +import { + disableAuthBypassForSession, + getBypassAuthUser, + isAuthBypassEnabled, +} from "@/lib/auth/auth-bypass"; import { clearAuth, getAuthUser, isSessionValid, setAuthUser } from "@/lib/auth/auth-storage"; import type { AuthUser, @@ -22,10 +27,18 @@ export async function login(credentials: LoginCredentials): Promise { } export async function logout(): Promise { + if (isAuthBypassEnabled()) { + disableAuthBypassForSession(); + } + clearAuth(); } export async function getSession(): Promise { + if (isAuthBypassEnabled()) { + return getBypassAuthUser(); + } + const user = getAuthUser(); if (!user || !isSessionValid(user)) { diff --git a/src/domain/uplifts/api/uplifts-api.ts b/src/domain/uplifts/api/uplifts-api.ts index bdb3fdc3..2ab93ab3 100644 --- a/src/domain/uplifts/api/uplifts-api.ts +++ b/src/domain/uplifts/api/uplifts-api.ts @@ -4,7 +4,7 @@ import { handleApiResponse } from "@/api/handle-api-response"; import { mapUpliftQueueItems } from "@/domain/uplifts/mappers/uplift-mapper"; import type { UpliftsQueueParams, UpliftsQueueResult } from "@/domain/uplifts/types/uplift"; import { mapUpliftRequests } from "@/domain/work-orders/mappers/work-order-mapper"; -import type { UpliftRequest } from "@/domain/work-orders/types/work-order"; +import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; function buildQueueParams(params: UpliftsQueueParams): Record { const searchParams: Record = { diff --git a/src/domain/uplifts/types/uplift.ts b/src/domain/uplifts/types/uplift.ts index 8d6d3503..b023040d 100644 --- a/src/domain/uplifts/types/uplift.ts +++ b/src/domain/uplifts/types/uplift.ts @@ -1,4 +1,18 @@ -import type { UpliftRequest } from "@/domain/work-orders/types/work-order"; +export interface UpliftRequest { + id: string | number; + status: string; + currentNTE: number | null; + requestedNTE: number; + delta: number; + vendorReason: string; + requestedAt: string; + requestedByVendorName: string; + decidedAt: string; + decidedByName: string; + decisionNote: string; + requiredTier: number; + canDecide: boolean; +} export interface UpliftQueueItem extends UpliftRequest { dispatchNumber: string; diff --git a/src/domain/uplifts/use-cases/use-uplift-actions.ts b/src/domain/uplifts/use-cases/use-uplift-actions.ts index 071ce7e3..144defb1 100644 --- a/src/domain/uplifts/use-cases/use-uplift-actions.ts +++ b/src/domain/uplifts/use-cases/use-uplift-actions.ts @@ -7,7 +7,7 @@ import { } from "@tanstack/react-query"; import { toast } from "react-toastify"; import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; -import type { UpliftRequest } from "@/domain/work-orders/types/work-order"; +import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; import { queryKeys } from "@/infra/query-key/query-key"; import { requireQueryParam } from "@/lib/query/require-query-param"; @@ -33,12 +33,12 @@ function invalidateUpliftQueries( } } -interface ApproveUpliftInput { +export interface ApproveUpliftInput { id: string | number; note?: string; } -interface DenyUpliftInput { +export interface DenyUpliftInput { id: string | number; note: string; } diff --git a/src/domain/vendor-pos/types/vendor-po.ts b/src/domain/vendor-pos/types/vendor-po.ts index d1fef92b..784366f3 100644 --- a/src/domain/vendor-pos/types/vendor-po.ts +++ b/src/domain/vendor-pos/types/vendor-po.ts @@ -1,4 +1,4 @@ -import type { UpliftRequest } from "@/domain/work-orders/types/work-order"; +import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; export interface VendorPoListItem { id: string | number; diff --git a/src/hooks/use-menu-anchor.ts b/src/hooks/use-menu-anchor.ts new file mode 100644 index 00000000..c4f06816 --- /dev/null +++ b/src/hooks/use-menu-anchor.ts @@ -0,0 +1,11 @@ +import { useCallback, useState } from "react"; + +export function useMenuAnchor() { + const [anchorEl, setAnchorEl] = useState(null); + + const open = Boolean(anchorEl); + const setAnchor = useCallback((element: HTMLElement) => setAnchorEl(element), []); + const close = useCallback(() => setAnchorEl(null), []); + + return { anchorEl, open, setAnchor, close }; +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index 9d14bc46..21544914 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -1,3 +1,5 @@ +import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list"; + export const queryKeys = { auth: { all: ["auth"] as const, @@ -5,8 +7,10 @@ export const queryKeys = { }, workOrders: { all: ["workOrders"] as const, - list: (params?: Record) => + list: (params?: WorkOrdersListParams) => [...queryKeys.workOrders.all, "list", params ?? {}] as const, + byLocation: (locationId: string | number, search = "") => + [...queryKeys.workOrders.all, "byLocation", locationId, search] as const, detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const, dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const, vendors: (trade?: string, siteZip?: string) => diff --git a/src/lib/auth/auth-bypass.ts b/src/lib/auth/auth-bypass.ts new file mode 100644 index 00000000..40e12fea --- /dev/null +++ b/src/lib/auth/auth-bypass.ts @@ -0,0 +1,36 @@ +import type { AuthUser } from "@/domain/auth/types/auth-user"; +import { env } from "@/lib/env"; + +const BYPASS_DISABLED_KEY = "auth-bypass-disabled"; + +export function isAuthBypassEnabled(): boolean { + if (!env.isDev || !env.authBypass) { + return false; + } + + if (typeof window !== "undefined" && sessionStorage.getItem(BYPASS_DISABLED_KEY)) { + return false; + } + + return true; +} + +export function getBypassAuthUser(): AuthUser { + return { + token: "dev-bypass-token", + expiration: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + email: "dev-bypass@seahaven.test", + userRoles: "Admin", + phoneNumber: "", + fullname: "Dev Bypass User", + id: "dev-bypass", + }; +} + +export function disableAuthBypassForSession(): void { + if (typeof window === "undefined") { + return; + } + + sessionStorage.setItem(BYPASS_DISABLED_KEY, "1"); +} diff --git a/src/lib/env.ts b/src/lib/env.ts index 51b5d14a..d22d5b55 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,4 +1,5 @@ export const env = { apiUrl: import.meta.env.VITE_API_URL?.trim() || "/api", isDev: import.meta.env.DEV, + authBypass: import.meta.env.VITE_AUTH_BYPASS === "true", } as const; diff --git a/src/lib/time-utils.ts b/src/lib/time-utils.ts index a0a0e8c0..b2933891 100644 --- a/src/lib/time-utils.ts +++ b/src/lib/time-utils.ts @@ -99,6 +99,19 @@ export function formatDateTime(value: string): string { return `${datePart} ${timePart} ${tz ?? ""}`.trim(); } +export function getWaitTimeTextClass(color: "green" | "yellow" | "red" | ""): string | undefined { + if (color === "green") { + return "text-green-600"; + } + if (color === "yellow") { + return "text-yellow-600"; + } + if (color === "red") { + return "text-red-600"; + } + return undefined; +} + export function toDateInputValue(value: string | null | undefined): string { if (!value) { return ""; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 4df1f98f..7c85de03 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -3,6 +3,8 @@ interface ImportMetaEnv { readonly VITE_API_URL: string; readonly VITE_API_TARGET: string; + readonly VITE_AUTH_BYPASS?: string; + readonly VITE_WO_TABLE_MOCK?: string; } interface ImportMeta {