shoc-frontend-new/src/app/(protected)/vendors/_components/vendor-table-row.tsx
Codex Review Integration 2bff7176be feat(vendors): add Service Area assignment, filter and column
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.
2026-09-16 11:55:40 -03:00

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>
);
}