mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Replace the free-text vendor State with a closed 50-state list, add an admin-only Service Area select below Notes, a Service Area advanced filter (stable ids plus Unassigned) and a Service Area directory column.
340 lines
9.2 KiB
TypeScript
340 lines
9.2 KiB
TypeScript
import { type KeyboardEvent, type MouseEvent, type ReactNode } from "react";
|
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
|
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
|
import { Avatar, Box, IconButton, Link, Stack, TableCell, TableRow, Tooltip } from "@mui/material";
|
|
import { VendorStatusBadge } from "./vendor-status-badge";
|
|
import { Text } from "@/components/ui/text";
|
|
import type { VendorDirectoryTechnician, VendorListItem } from "@/domain/vendors/types/vendor";
|
|
|
|
function stopPropagation(event: MouseEvent): void {
|
|
event.stopPropagation();
|
|
}
|
|
function stopBadgeKeyDown(event: KeyboardEvent): void {
|
|
if (event.key !== "Enter" && event.key !== " ") return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
|
|
const truncatedTextSx = {
|
|
display: "block",
|
|
minWidth: 0,
|
|
maxWidth: "100%",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
};
|
|
|
|
function TruncatedText({
|
|
value,
|
|
testId,
|
|
focusable = false,
|
|
href,
|
|
variant,
|
|
}: {
|
|
value: string;
|
|
testId: string;
|
|
focusable?: boolean;
|
|
href?: string;
|
|
variant?: "description";
|
|
}) {
|
|
const hasValue = value !== "—";
|
|
const content = href ? (
|
|
<Link
|
|
href={href}
|
|
underline="hover"
|
|
color="inherit"
|
|
data-testid={testId}
|
|
sx={truncatedTextSx}
|
|
onClick={stopPropagation}
|
|
>
|
|
{value}
|
|
</Link>
|
|
) : variant ? (
|
|
<Text variant={variant} data-testid={testId} sx={{ ...truncatedTextSx, flex: "1 1 auto" }}>
|
|
{value}
|
|
</Text>
|
|
) : (
|
|
<Box
|
|
component="span"
|
|
data-testid={testId}
|
|
tabIndex={focusable && hasValue ? 0 : undefined}
|
|
sx={truncatedTextSx}
|
|
>
|
|
{value}
|
|
</Box>
|
|
);
|
|
return (
|
|
<Tooltip title={hasValue ? value : ""} placement="top" arrow describeChild>
|
|
{content}
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function OverflowBadge({
|
|
count,
|
|
label,
|
|
kind,
|
|
}: {
|
|
count: number;
|
|
label: string;
|
|
kind: "trade" | "technician";
|
|
}) {
|
|
const noun = count === 1 ? kind : `${kind}s`;
|
|
const ariaLabel = `+${count} more ${noun}${kind === "trade" ? `: ${label}` : ""}`;
|
|
return (
|
|
<Tooltip title={label} placement="top" arrow>
|
|
<Box
|
|
component="span"
|
|
tabIndex={0}
|
|
aria-label={ariaLabel}
|
|
data-testid={`vendor-${kind}-badge`}
|
|
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--color-primary)]"
|
|
onClick={stopPropagation}
|
|
onKeyDown={stopBadgeKeyDown}
|
|
sx={{
|
|
flexShrink: 0,
|
|
borderRadius: "9999px",
|
|
backgroundColor: "var(--color-bg-muted)",
|
|
px: 0.75,
|
|
py: 0.25,
|
|
fontSize: 10.5,
|
|
lineHeight: 1.2,
|
|
}}
|
|
>
|
|
+{count}
|
|
</Box>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function CellText({
|
|
value,
|
|
testId,
|
|
href,
|
|
focusable,
|
|
}: {
|
|
value: string;
|
|
testId: string;
|
|
href?: string;
|
|
focusable?: boolean;
|
|
}) {
|
|
return (
|
|
<TableCell sx={{ color: "var(--color-muted-foreground)", overflow: "hidden" }}>
|
|
<TruncatedText
|
|
value={value.trim() || "—"}
|
|
testId={testId}
|
|
href={href}
|
|
focusable={focusable}
|
|
/>
|
|
</TableCell>
|
|
);
|
|
}
|
|
|
|
function CompanyCells({ row }: { row: VendorListItem }) {
|
|
const phone = row.companyPhone.trim();
|
|
const email = row.email.trim();
|
|
return (
|
|
<>
|
|
<TableCell sx={{ overflow: "hidden" }}>
|
|
<TruncatedText
|
|
value={row.companyName || "—"}
|
|
testId="vendor-text-company"
|
|
variant="description"
|
|
/>
|
|
</TableCell>
|
|
<CellText
|
|
value={phone}
|
|
testId="vendor-text-company-phone"
|
|
href={phone ? `tel:${phone}` : undefined}
|
|
/>
|
|
<CellText
|
|
value={email}
|
|
testId="vendor-text-company-email"
|
|
href={email ? `mailto:${email}` : undefined}
|
|
/>
|
|
<CellText value={row.address} testId="vendor-text-street-address" focusable />
|
|
<CellText value={row.city} testId="vendor-text-city" focusable />
|
|
<CellText value={row.state} testId="vendor-text-state" focusable />
|
|
<CellText value={row.areaName || "Unassigned"} testId="vendor-text-service-area" focusable />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function orderedTechnicians(row: VendorListItem): VendorDirectoryTechnician[] {
|
|
const technicians = row.technicians ?? [];
|
|
const primaryIndex =
|
|
row.id == null ? -1 : technicians.findIndex((item) => String(item.id) === String(row.id));
|
|
if (primaryIndex <= 0) return technicians;
|
|
return [technicians[primaryIndex]!, ...technicians.filter((_, index) => index !== primaryIndex)];
|
|
}
|
|
|
|
function primaryTechnician(row: VendorListItem, technicians: VendorDirectoryTechnician[]) {
|
|
return (
|
|
technicians[0] ?? {
|
|
id: row.id ?? "",
|
|
contactName: row.contactName,
|
|
email: "",
|
|
phone: row.phone,
|
|
preferredContact: row.preferredContact,
|
|
tradeSpecialties: row.tradeSpecialties,
|
|
isActive: row.isActive,
|
|
totalJobs: row.totalJobs,
|
|
}
|
|
);
|
|
}
|
|
|
|
function TradeCell({ value }: { value: string }) {
|
|
const trades = value
|
|
.split(",")
|
|
.map((trade) => trade.trim())
|
|
.filter(Boolean);
|
|
const omitted = trades.slice(1);
|
|
return (
|
|
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
|
|
{trades.length === 0 ? (
|
|
"—"
|
|
) : (
|
|
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
|
|
<span>{trades[0]}</span>
|
|
{omitted.length > 0 && (
|
|
<OverflowBadge count={omitted.length} label={omitted.join(", ")} kind="trade" />
|
|
)}
|
|
</Stack>
|
|
)}
|
|
</TableCell>
|
|
);
|
|
}
|
|
|
|
function getInitials(name: string): string {
|
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
if (parts.length === 0) return "?";
|
|
return `${parts[0]?.[0] ?? ""}${parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : ""}`.toUpperCase();
|
|
}
|
|
|
|
function TechnicianCells({ row }: { row: VendorListItem }) {
|
|
const technicians = orderedTechnicians(row);
|
|
const primary = primaryTechnician(row, technicians);
|
|
const omitted = technicians.slice(1);
|
|
const phone = primary.phone.trim();
|
|
return (
|
|
<>
|
|
<TableCell sx={{ overflow: "hidden" }}>
|
|
<Stack
|
|
direction="row"
|
|
spacing={1.25}
|
|
sx={{ alignItems: "center", minWidth: 0, overflow: "hidden" }}
|
|
>
|
|
<Avatar
|
|
sx={{
|
|
width: 28,
|
|
height: 28,
|
|
flexShrink: 0,
|
|
backgroundColor: "var(--color-bg-muted)",
|
|
color: "var(--color-primary)",
|
|
fontSize: 10.5,
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
{getInitials(primary.contactName)}
|
|
</Avatar>
|
|
<TruncatedText
|
|
value={primary.contactName || "—"}
|
|
testId="vendor-text-technician"
|
|
variant="description"
|
|
/>
|
|
{omitted.length > 0 && (
|
|
<OverflowBadge
|
|
count={omitted.length}
|
|
label={omitted.map((item) => item.contactName || "Unnamed technician").join(", ")}
|
|
kind="technician"
|
|
/>
|
|
)}
|
|
</Stack>
|
|
</TableCell>
|
|
<CellText
|
|
value={phone}
|
|
testId="vendor-text-technician-phone"
|
|
href={phone ? `tel:${phone}` : undefined}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function RowActions({
|
|
row,
|
|
onOpenDetail,
|
|
onOpenEdit,
|
|
}: {
|
|
row: VendorListItem;
|
|
onOpenDetail: () => void;
|
|
onOpenEdit: () => void;
|
|
}) {
|
|
const action = (label: string, icon: ReactNode, handler: () => void) => (
|
|
<Tooltip title={label}>
|
|
<IconButton
|
|
size="small"
|
|
aria-label={`${label} vendor ${row.companyName}`}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
handler();
|
|
}}
|
|
>
|
|
{icon}
|
|
</IconButton>
|
|
</Tooltip>
|
|
);
|
|
return (
|
|
<TableCell align="right">
|
|
<Stack
|
|
direction="row"
|
|
spacing={0.5}
|
|
className="vendor-row-actions"
|
|
sx={{ alignItems: "center", justifyContent: "flex-end" }}
|
|
>
|
|
{action("View", <VisibilityOutlinedIcon fontSize="small" />, onOpenDetail)}
|
|
{action("Edit", <EditOutlinedIcon fontSize="small" />, onOpenEdit)}
|
|
</Stack>
|
|
</TableCell>
|
|
);
|
|
}
|
|
|
|
export function VendorTableRow({
|
|
row,
|
|
onOpenDetail,
|
|
onOpenEdit,
|
|
}: {
|
|
row: VendorListItem;
|
|
onOpenDetail: (row: VendorListItem) => void;
|
|
onOpenEdit: (row: VendorListItem) => void;
|
|
}) {
|
|
const openDetail = () => onOpenDetail(row);
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.target instanceof Element && event.target.closest("a, button")) return;
|
|
if (event.key !== "Enter" && event.key !== " ") return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
openDetail();
|
|
};
|
|
return (
|
|
<TableRow
|
|
hover
|
|
tabIndex={0}
|
|
role="button"
|
|
aria-label={`Open vendor details for ${row.companyName}`}
|
|
sx={{ cursor: "pointer" }}
|
|
onClick={openDetail}
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
<CompanyCells row={row} />
|
|
<TradeCell value={row.tradeSpecialties} />
|
|
<TechnicianCells row={row} />
|
|
<TableCell align="center">{row.totalJobs ?? 0}</TableCell>
|
|
<TableCell>
|
|
<Box sx={{ pl: 1 }}>
|
|
<VendorStatusBadge isActive={row.isActive} />
|
|
</Box>
|
|
</TableCell>
|
|
<RowActions row={row} onOpenDetail={openDetail} onOpenEdit={() => onOpenEdit(row)} />
|
|
</TableRow>
|
|
);
|
|
}
|