mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 21:42:06 +00:00
* fix(vendors): truncate long locations in table * fix(vendors): truncate textual table cells --------- Co-authored-by: Codex Review Integration <codex-review@local.invalid>
413 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|