shoc-frontend-new/src/app/(protected)/vendors/_components/vendors-table.tsx
Alexandre Brandizzi 5e38c93b1e
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
fix(vendors): truncate long textual table values (#154)
* fix(vendors): truncate long locations in table

* fix(vendors): truncate textual table cells

---------

Co-authored-by: Codex Review Integration <codex-review@local.invalid>
2026-08-28 11:21:15 -04:00

413 lines
12 KiB
TypeScript

import { type KeyboardEvent, type MouseEvent } from "react";
import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import {
Avatar,
Box,
CircularProgress,
IconButton,
Link,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tooltip,
} from "@mui/material";
import { VendorStatusBadge } from "./vendor-status-badge";
import { Text } from "@/components/ui/text";
import type { VendorListItem } from "@/domain/vendors/types/vendor";
const TABLE_COLUMNS = [
"Company",
"Technician",
"Location",
"Trade",
"Phone",
"Email",
"Total Jobs",
] as const;
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
const first = parts[0]?.[0] ?? "";
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : "";
return (first + last).toUpperCase();
}
function splitTrades(value: string | undefined): string[] {
return (value ?? "")
.split(",")
.map((trade) => trade.trim())
.filter(Boolean);
}
function stopPropagation(event: MouseEvent): void {
event.stopPropagation();
}
interface TruncatedTextProps {
value: string;
testId: string;
focusable?: boolean;
href?: string;
color?: string;
variant?: "description";
}
const truncatedTextSx = {
display: "block",
minWidth: 0,
maxWidth: "100%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};
function TruncatedText({
value,
testId,
focusable = false,
href,
color = "inherit",
variant,
}: TruncatedTextProps) {
const hasValue = value !== "—";
const content = href ? (
<Link
href={href}
underline="hover"
color="inherit"
data-testid={testId}
sx={{ ...truncatedTextSx, color }}
onClick={stopPropagation}
>
{value}
</Link>
) : variant ? (
<Text
variant={variant}
data-testid={testId}
sx={{ ...truncatedTextSx, color, flex: "1 1 auto" }}
>
{value}
</Text>
) : (
<Box
component="span"
data-testid={testId}
tabIndex={focusable && hasValue ? 0 : undefined}
sx={{ ...truncatedTextSx, color }}
>
{value}
</Box>
);
return (
<Tooltip title={hasValue ? value : ""} placement="top" arrow describeChild>
{content}
</Tooltip>
);
}
function getVendorDisplayValues(row: VendorListItem) {
const cityStateText = [row.city.trim(), row.state.trim()].filter(Boolean).join(", ");
return {
locationText: cityStateText || row.address.trim() || "—",
phoneText: row.phone.trim() || row.companyPhone.trim(),
};
}
interface VendorTableRowProps {
row: VendorListItem;
onOpenDetail: (row: VendorListItem) => void;
onOpenEdit: (row: VendorListItem) => void;
}
function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) {
const trades = splitTrades(row.tradeSpecialties);
const primaryTrade = trades[0] ?? "—";
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
const { locationText, phoneText } = getVendorDisplayValues(row);
const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`);
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
onOpenDetail(row);
};
return (
<TableRow
key={rowKey}
hover
tabIndex={0}
role="button"
aria-label={`Open vendor details for ${row.companyName}`}
sx={{ cursor: "pointer" }}
onClick={() => onOpenDetail(row)}
onKeyDown={handleKeyDown}
>
<TableCell sx={{ overflow: "hidden" }}>
<TruncatedText
value={row.companyName || "—"}
testId="vendor-text-company"
variant="description"
color="var(--color-foreground)"
/>
</TableCell>
<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(row.contactName)}
</Avatar>
<TruncatedText
value={row.contactName || "—"}
testId="vendor-text-technician"
variant="description"
color="var(--color-foreground)"
/>
</Stack>
</TableCell>
<TableCell sx={{ color: "var(--color-muted-foreground)", overflow: "hidden" }}>
<TruncatedText
value={locationText}
testId="vendor-text-location"
focusable
color="var(--color-muted-foreground)"
/>
</TableCell>
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
{primaryTrade === "—" ? (
"—"
) : (
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
<span>{primaryTrade}</span>
{extraTrades > 0 && (
<Box
component="span"
sx={{
borderRadius: "9999px",
backgroundColor: "var(--color-bg-muted)",
px: 0.75,
py: 0.25,
fontSize: 10.5,
lineHeight: 1.2,
}}
>
+{extraTrades}
</Box>
)}
</Stack>
)}
</TableCell>
<TableCell sx={{ color: "var(--color-muted-foreground)", overflow: "hidden" }}>
<TruncatedText
value={phoneText || "—"}
testId="vendor-text-phone"
href={phoneText ? `tel:${phoneText}` : undefined}
color="var(--color-muted-foreground)"
/>
</TableCell>
<TableCell sx={{ overflow: "hidden" }}>
<TruncatedText
value={row.email || "—"}
testId="vendor-text-email"
href={row.email ? `mailto:${row.email}` : undefined}
color="#6b7a96"
/>
</TableCell>
<TableCell align="center">{row.totalJobs ?? 0}</TableCell>
<TableCell>
<Box sx={{ pl: 1 }}>
<VendorStatusBadge isActive={row.isActive} />
</Box>
</TableCell>
<TableCell align="right">
<Stack
direction="row"
spacing={0.5}
className="vendor-row-actions"
sx={{ alignItems: "center", justifyContent: "flex-end" }}
>
<Tooltip title="View details">
<IconButton
size="small"
aria-label={`View vendor ${row.companyName}`}
onClick={(event) => {
event.stopPropagation();
onOpenDetail(row);
}}
>
<VisibilityOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Edit">
<IconButton
size="small"
aria-label={`Edit vendor ${row.companyName}`}
onClick={(event) => {
event.stopPropagation();
onOpenEdit(row);
}}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</TableCell>
</TableRow>
);
}
interface VendorsTableProps {
rows: VendorListItem[];
isActive: boolean;
isFiltered: boolean;
isLoading: boolean;
showError: boolean;
onOpenDetail: (row: VendorListItem) => void;
onOpenEdit: (row: VendorListItem) => void;
}
export function VendorsTable({
rows,
isActive,
isFiltered,
isLoading,
showError,
onOpenDetail,
onOpenEdit,
}: VendorsTableProps) {
const showEmptyRow = !isLoading && !showError && rows.length === 0;
const columnCount = TABLE_COLUMNS.length + 2;
return (
<TableContainer
component={Paper}
variant="outlined"
sx={{
mt: 2,
mb: 2,
flex: "1 1 auto",
overflow: "auto",
borderRadius: 1.5,
fontSize: 13,
}}
>
<Table
size="small"
stickyHeader
sx={{
minWidth: 1550,
tableLayout: "fixed",
"& .MuiTableCell-head": {
backgroundColor: "var(--color-bg-muted)",
color: "var(--color-foreground)",
fontSize: 10.5,
fontWeight: 700,
letterSpacing: "0.06em",
lineHeight: "24px",
px: 1.75,
py: 1.375,
textTransform: "uppercase",
whiteSpace: "nowrap",
},
"& .MuiTableCell-body": {
fontSize: 13,
px: 1.5,
py: 1,
height: 52,
lineHeight: 1.43,
whiteSpace: "nowrap",
},
"& .vendor-row-actions": {
opacity: { xs: 1, md: 0 },
transition: "opacity 120ms ease",
},
"& .MuiTableRow-root:hover .vendor-row-actions, & .MuiTableRow-root:focus-within .vendor-row-actions":
{
opacity: 1,
},
}}
>
<colgroup>
<col style={{ width: 196.34375 }} />
<col style={{ width: 207.25 }} />
<col style={{ width: 152.703125 }} />
<col style={{ width: 218.15625 }} />
<col style={{ width: 152.703125 }} />
<col style={{ width: 251.90625 }} />
<col style={{ width: 109.078125 }} />
<col style={{ width: 119.984375 }} />
<col style={{ width: 141.875 }} />
</colgroup>
<TableHead>
<TableRow>
{TABLE_COLUMNS.map((column) => (
<TableCell key={column} align={column === "Total Jobs" ? "center" : "left"}>
{column}
</TableCell>
))}
<TableCell>Status</TableCell>
<TableCell align="right" aria-label="Vendor actions" />
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columnCount} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : showEmptyRow ? (
<TableRow>
<TableCell colSpan={columnCount} align="center" className="py-10">
<Stack spacing={1} sx={{ alignItems: "center" }}>
<SearchOffOutlinedIcon sx={{ color: "text.secondary", fontSize: 32 }} />
<Text variant="description" className="font-semibold">
{`No ${isActive ? "active" : "inactive"} technicians found`}
</Text>
<Text variant="caption" tone="muted">
{isFiltered
? "Try a different search, or add a new vendor."
: "Add a vendor to get started."}
</Text>
</Stack>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<VendorTableRow
key={String(row.id ?? `company-${row.companyId ?? ""}`)}
row={row}
onOpenDetail={onOpenDetail}
onOpenEdit={onOpenEdit}
/>
))
)}
</TableBody>
</Table>
</TableContainer>
);
}