,
+ "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..04308315
--- /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..c2951f72 100644
--- a/src/infra/query-key/query-key.ts
+++ b/src/infra/query-key/query-key.ts
@@ -1,3 +1,5 @@
+type WorkOrdersListParams = Record;
+
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 {