mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 18:12:05 +00:00
Three divergences in the vendor detail drawer: - The header rendered the company as a subtitle under a title that had already fallen back to the company, printing the same name twice when the row carried no technician. The subtitle is now dropped when it would repeat the title. - The Google Maps link appeared only when a URL was stored, unlabelled, so an empty value left no trace of the field. It is now a labelled field below Address that reads em-dash when unset, as every other field in the panel does. - The status sat immediately beside Total Jobs. It now sits opposite it at the right edge, and reuses the vendors table's dot-plus-text badge so the state never reads by colour alone.
335 lines
9.7 KiB
TypeScript
335 lines
9.7 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 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 = [row.city, row.state].filter(Boolean).join(", ") || "—";
|
|
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>
|
|
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
|
|
{row.companyName || "—"}
|
|
</Text>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
|
|
<Avatar
|
|
sx={{
|
|
width: 28,
|
|
height: 28,
|
|
backgroundColor: "var(--color-bg-muted)",
|
|
color: "var(--color-primary)",
|
|
fontSize: 10.5,
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
{getInitials(row.contactName)}
|
|
</Avatar>
|
|
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
|
|
{row.contactName || "—"}
|
|
</Text>
|
|
</Stack>
|
|
</TableCell>
|
|
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>{locationText}</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)" }}>
|
|
{row.phone ? (
|
|
<Link
|
|
href={`tel:${row.phone}`}
|
|
underline="hover"
|
|
color="inherit"
|
|
onClick={stopPropagation}
|
|
>
|
|
{row.phone}
|
|
</Link>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{row.email ? (
|
|
<Link
|
|
href={`mailto:${row.email}`}
|
|
underline="hover"
|
|
sx={{ color: "#6b7a96" }}
|
|
onClick={stopPropagation}
|
|
>
|
|
{row.email}
|
|
</Link>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</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>
|
|
);
|
|
}
|