mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
* 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>
482 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|