shoc-frontend-new/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx
Alexandre Brandizzi 338cc17b8a
fix(vendors): remove duplicate company heading (#139)
* fix(vendors): remove duplicate company heading

* test(vendors): align detail baseline with CI

---------

Co-authored-by: Codex Review Integration <codex-review@local.invalid>
Co-authored-by: Adam Moussa <166072409+amoussa1229@users.noreply.github.com>
2026-08-26 16:45:06 +00:00

482 lines
14 KiB
TypeScript

import { useEffect, useState, type ReactNode } from "react";
import { useWatch } from "react-hook-form";
import CloseIcon from "@mui/icons-material/Close";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import LaunchIcon from "@mui/icons-material/Launch";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Divider,
Drawer,
IconButton,
Link,
Stack,
Switch,
} from "@mui/material";
import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
import { VendorStatusBadge } from "./vendor-status-badge";
import { VendorRosterFormFields } from "./vendor-roster-form-fields";
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
import { useVendorRosterForm } from "./use-vendor-roster-form";
import { Text } from "@/components/ui/text";
import type {
VendorCompanyRoster,
VendorListItem,
VendorRosterTechnician,
} from "@/domain/vendors/types/vendor";
import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster";
const DRAWER_WIDTH = 420;
function splitTrades(value: string | undefined): string[] {
return (value ?? "")
.split(",")
.map((trade) => trade.trim())
.filter(Boolean);
}
interface VendorDetailDrawerProps {
open: boolean;
vendor: VendorListItem | null;
startInEdit?: boolean;
onRequestDeactivation?: (vendor: VendorListItem) => void;
onClose: () => void;
}
function SectionTitle({ children }: { children: ReactNode }) {
return (
<Text
variant="label"
tone="muted"
className="font-semibold tracking-[0.06em] uppercase"
sx={{ mb: 1 }}
>
{children}
</Text>
);
}
function Field({ label, value }: { label: string; value?: string }) {
const display = !value || value.trim() === "" ? "—" : value;
return (
<Box>
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
{label}
</Text>
<Text variant="description">{display}</Text>
</Box>
);
}
function DrawerHeader({
roster,
vendor,
technician,
onClose,
}: {
roster?: VendorCompanyRoster;
vendor: VendorListItem | null;
technician?: VendorRosterTechnician;
onClose: () => void;
}) {
const title = technician?.contactName || vendor?.contactName || roster?.name || "Vendor company";
const company = roster?.name || vendor?.companyName || "Vendor details";
// The company is a subtitle under the technician's name. When there is no
// technician the title already falls back to the company, and repeating it
// would render the same line twice.
const subtitle = company === title ? null : company;
return (
<Stack
direction="row"
sx={{
alignItems: "center",
justifyContent: "space-between",
px: 2.5,
py: 2,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<Box>
<Text id="vendor-detail-drawer-title" variant="title" as="h2" className="font-semibold">
{title}
</Text>
{subtitle !== null && (
<Text variant="description" tone="muted">
{subtitle}
</Text>
)}
</Box>
<IconButton aria-label="Close drawer" onClick={onClose} size="small">
<CloseIcon fontSize="small" />
</IconButton>
</Stack>
);
}
function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
const address = [roster.address, roster.city, roster.state, roster.zip]
.filter(Boolean)
.join(", ");
return (
<Stack spacing={1.5}>
<Field label="Company" value={roster.name} />
<Field label="Company Phone" value={roster.companyPhone} />
<Field label="Company Email" value={roster.email} />
<Field label="Address" value={address} />
<Box>
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
Google Maps
</Text>
{roster.googleMapsUrl ? (
<Link
href={roster.googleMapsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1"
>
<LaunchIcon fontSize="small" />
Open in Google Maps
</Link>
) : (
<Text variant="description">—</Text>
)}
</Box>
</Stack>
);
}
function TechnicianSection({ technician }: { technician: VendorRosterTechnician }) {
const trades = splitTrades(technician.tradeSpecialties);
return (
<Box>
<Stack spacing={1.5}>
<Field label="Phone" value={technician.phone} />
<Box>
<Text variant="caption" tone="muted" sx={{ display: "block", mb: 0.75 }}>
Trade
</Text>
<Stack direction="row" spacing={0.75} sx={{ flexWrap: "wrap" }}>
{trades.length > 0 ? (
trades.map((trade, index) => (
<Chip key={`${trade}-${index}`} size="small" variant="outlined" label={trade} />
))
) : (
<Text variant="description">—</Text>
)}
</Stack>
</Box>
<Stack direction="row" sx={{ alignItems: "flex-end", justifyContent: "space-between" }}>
<Box>
<Text variant="title">{technician.totalJobs ?? 0}</Text>
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
Total Jobs
</Text>
</Box>
<VendorStatusBadge isActive={technician.isActive} />
</Stack>
</Stack>
</Box>
);
}
function DrawerBody({
roster,
technician,
}: {
roster: VendorCompanyRoster;
technician?: VendorRosterTechnician;
}) {
return (
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ p: 2.5 }}>
<Box>
<CompanySection roster={roster} />
</Box>
<Divider />
{technician ? (
<TechnicianSection technician={technician} />
) : (
<Text variant="description" tone="muted">
No technicians assigned to this company.
</Text>
)}
{Boolean(roster.notes) && (
<>
<Divider />
<Box>
<SectionTitle>Notes</SectionTitle>
<Text variant="description" sx={{ overflowWrap: "anywhere", whiteSpace: "pre-wrap" }}>
{roster.notes}
</Text>
</Box>
</>
)}
</Stack>
);
}
function DrawerActions({ onEdit }: { onEdit: () => void }) {
return (
<Stack
direction="row"
sx={{
px: 2.5,
py: 1.5,
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
alignItems: "center",
justifyContent: "flex-end",
}}
>
<Button variant="contained" onClick={onEdit} startIcon={<EditOutlinedIcon />}>
Edit
</Button>
</Stack>
);
}
function TechnicianStatusControl({
isActive,
persistedIsActive,
onRequestDeactivation,
onChange,
}: {
isActive: boolean;
persistedIsActive: boolean;
onRequestDeactivation: () => void;
onChange: (checked: boolean) => void;
}) {
return (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Text variant="description">{isActive ? "Active" : "Inactive"}</Text>
<Switch
checked={isActive}
slotProps={{ input: { "aria-label": "Active status" } }}
onChange={(_event, checked) => {
if (persistedIsActive && !checked) onRequestDeactivation();
else onChange(checked);
}}
/>
</Stack>
);
}
function DrawerEditor({
vendor,
onClose,
onSaved,
onRequestDeactivation = () => undefined,
}: {
vendor: VendorListItem;
onClose: () => void;
onSaved: () => void;
onRequestDeactivation: (vendor: VendorListItem) => void;
}) {
const form = useVendorRosterForm({
mode: "update",
vendorId: vendor.id ?? undefined,
companyId: vendor.id == null ? (vendor.companyId ?? undefined) : undefined,
onSuccess: onSaved,
});
const roster = form.roster;
const technicians = useWatch({ control: form.control, name: "technicians" }) ?? [];
const selectedIndex = technicians.findIndex(
(technician) => technician.id != null && String(technician.id) === String(vendor.id),
);
const persistedTechnician = roster?.technicians.find(
(technician) => technician.id != null && String(technician.id) === String(vendor.id),
);
const selectedTotalJobs = persistedTechnician?.totalJobs ?? vendor.totalJobs;
// Deactivation is a property of the saved vendor, not of the toggle: flipping an
// inactive vendor on and back off without saving must not ask to deactivate it.
const persistedIsActive = persistedTechnician?.isActive ?? vendor.isActive;
if (form.isLoading) {
return <CircularProgress size={28} sx={{ m: "auto" }} />;
}
if (form.isError || !roster) {
return (
<Alert severity="error" sx={{ m: 2 }}>
Failed to load vendor company. Try again.
</Alert>
);
}
return (
<Box
component="form"
id="vendor-drawer-edit-form"
onSubmit={form.handleSubmit(form.submit)}
className="flex min-h-0 flex-1 flex-col"
>
<Stack spacing={2} className="flex-1 overflow-y-auto" sx={{ p: 2.5 }}>
{form.loadErrorMessage !== null && (
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />
)}
{form.conflict !== null && (
<VendorRosterConflictAlert conflict={form.conflict} onReload={form.reload} />
)}
<VendorRosterFormFields
control={form.control}
errors={form.errors}
tradeOptions={form.trades}
tradeOptionsLoading={form.tradesLoading}
showTechnicianStatus={false}
/>
{selectedIndex >= 0 && (
<>
<Divider />
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Box>
<Text variant="title">{selectedTotalJobs}</Text>
<Text variant="caption" tone="muted">
Total Jobs
</Text>
</Box>
<TechnicianStatusControl
isActive={Boolean(technicians[selectedIndex]?.isActive)}
persistedIsActive={persistedIsActive}
onRequestDeactivation={() => onRequestDeactivation(vendor)}
onChange={(checked) => form.setTechnicianActive(selectedIndex, checked)}
/>
</Stack>
</>
)}
</Stack>
<Stack
direction="row"
spacing={1.5}
sx={{ p: 2, borderTop: 1, borderColor: "divider", justifyContent: "flex-end" }}
>
<Button onClick={onClose} disabled={form.isSaving}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={form.isSaving}>
{form.isSaving ? "Saving..." : "Save changes"}
</Button>
</Stack>
</Box>
);
}
function resolveTechnician(
roster: VendorCompanyRoster | undefined,
vendor: VendorListItem | null,
): VendorRosterTechnician | undefined {
if (!roster || !vendor) return undefined;
const match = roster.technicians.find(
(technician) =>
vendor.id != null && technician.id != null && String(technician.id) === String(vendor.id),
);
if (match) return match;
if (!vendor.contactName) return undefined;
return {
id: vendor.id ?? undefined,
contactName: vendor.contactName,
phone: vendor.phone,
email: vendor.email,
tradeSpecialties: vendor.tradeSpecialties,
isActive: vendor.isActive,
totalJobs: vendor.totalJobs,
};
}
export function VendorDetailDrawer({
open,
vendor,
startInEdit = false,
onRequestDeactivation = () => undefined,
onClose,
}: VendorDetailDrawerProps) {
const [isEditing, setIsEditing] = useState(startInEdit);
const vendorId = vendor?.id;
const companyId = vendor?.companyId;
const hasVendorId = vendorId != null;
const {
data: roster,
isLoading,
error,
} = useVendorCompanyRoster(
hasVendorId ? vendorId : undefined,
!hasVendorId ? (companyId ?? undefined) : undefined,
);
const selectedTechnician = resolveTechnician(roster, vendor);
useEffect(() => {
if (open) setIsEditing(startInEdit);
}, [open, startInEdit, vendor]);
useEffect(() => {
if (!open) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape" && !isEditing) onClose();
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [isEditing, onClose, open]);
return (
<Drawer
open={open}
onClose={(_event, reason) => {
if (reason === "escapeKeyDown" && isEditing) return;
onClose();
}}
anchor="right"
aria-labelledby="vendor-detail-drawer-title"
ModalProps={{ keepMounted: false }}
sx={{
"& .MuiDrawer-paper": {
width: { xs: "100%", sm: DRAWER_WIDTH },
maxWidth: "100%",
},
}}
>
<Stack className="flex h-full flex-col">
<DrawerHeader
roster={roster}
vendor={vendor}
technician={selectedTechnician}
onClose={onClose}
/>
{isEditing && vendor ? (
<DrawerEditor
vendor={vendor}
onClose={onClose}
onSaved={() => setIsEditing(false)}
onRequestDeactivation={onRequestDeactivation}
/>
) : isLoading ? (
<Box className="flex flex-1 items-center justify-center">
<CircularProgress size={28} />
</Box>
) : error ? (
<Alert severity="error" sx={{ m: 2 }}>
Failed to load vendor company. Try again.
</Alert>
) : roster ? (
<>
<DrawerBody roster={roster} technician={selectedTechnician} />
<DrawerActions onEdit={() => setIsEditing(true)} />
</>
) : (
<Box className="flex flex-1 items-center justify-center">
<Text variant="description" tone="muted">
No vendor selected.
</Text>
</Box>
)}
</Stack>
</Drawer>
);
}