mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
fix(vendors): remove preferred contact and fix trade ordering (#63)
* fix(vendors): address SH-180 and SH-182 * refactor(vendors): satisfy maintainability gates
This commit is contained in:
parent
2a37609b41
commit
4b88a60751
17 changed files with 1253 additions and 1116 deletions
12
e2e/vendors/vendors.spec.ts
vendored
12
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -313,21 +313,21 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
await page.getByRole("button", { name: "Add trade" }).click();
|
||||
await page.getByRole("combobox", { name: "Add Trade" }).fill("HVAC");
|
||||
await page.getByRole("button", { name: "Add trade" }).click();
|
||||
await expect(page.getByRole("button", { name: "Move Plumbing up" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Move HVAC down" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Move HVAC up" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Move Plumbing down" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Move HVAC up" }).click();
|
||||
await page.getByLabel("Notes").fill("Created in browser E2E");
|
||||
await page.getByRole("button", { name: "Text" }).click();
|
||||
await expect(page.getByRole("button", { name: "Text" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByText("Preferred Contact")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Add Vendor" }).last().click();
|
||||
await expect(page.getByRole("dialog", { name: "Add Vendor" })).toHaveCount(0);
|
||||
expect(state.createdBody).not.toHaveProperty("preferredContact");
|
||||
expect(state.createdBody).toMatchObject({
|
||||
companyName: "Gateway Plumbing",
|
||||
companyId: 101,
|
||||
contactName: "New Technician",
|
||||
companyPhone: "(314) 555-0100",
|
||||
preferredContact: "Text",
|
||||
tradeSpecialties: "HVAC, Plumbing",
|
||||
notes: "Created in browser E2E",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ test("edits only the vendor assigned to the current work order", async ({ page }
|
|||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/workorders/lookups/dispatchers", (route) =>
|
||||
fulfillJson(route, { data: [] }),
|
||||
);
|
||||
await page.route("**/api/User*", (route) => fulfillJson(route, { data: [] }));
|
||||
|
||||
await page.goto("/workorders/501");
|
||||
|
|
@ -68,10 +71,9 @@ test("edits only the vendor assigned to the current work order", async ({ page }
|
|||
await page.getByRole("button", { name: "Edit vendor" }).click();
|
||||
|
||||
await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeVisible();
|
||||
await expect(page.getByText("Preferred contact")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Technician name")).toHaveValue("Adam Whyte");
|
||||
await page.getByLabel("Technician name").fill("Taylor Tech");
|
||||
await page.getByLabel("Preferred contact").click();
|
||||
await page.getByRole("option", { name: "Text" }).click();
|
||||
await page.getByLabel("Phone").fill("3145550111");
|
||||
await page.getByLabel("Email").fill("taylor@example.com");
|
||||
await page.getByLabel("Notes").fill("Text before arrival");
|
||||
|
|
@ -82,7 +84,6 @@ test("edits only the vendor assigned to the current work order", async ({ page }
|
|||
expect(updateRequest?.body).toEqual({
|
||||
workOrderId: 501,
|
||||
contactName: "Taylor Tech",
|
||||
preferredContact: "Text",
|
||||
phone: "(314) 555-0111",
|
||||
email: "taylor@example.com",
|
||||
notes: "Text before arrival",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ const DEFAULT_VALUES: VendorFormValues = {
|
|||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
companyId: null,
|
||||
preferredContact: "Phone",
|
||||
};
|
||||
|
||||
function toFormValues(vendor: Vendor): VendorFormValues {
|
||||
|
|
@ -46,7 +45,6 @@ function toFormValues(vendor: Vendor): VendorFormValues {
|
|||
tradeSpecialties: vendor.tradeSpecialties ?? "",
|
||||
isActive: vendor.isActive,
|
||||
companyId: vendor.companyId ?? null,
|
||||
preferredContact: vendor.preferredContact ?? "Phone",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ const defaultValues: VendorFormValues = {
|
|||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
companyId: null,
|
||||
preferredContact: "Phone",
|
||||
};
|
||||
|
||||
export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateModalProps) {
|
||||
|
|
|
|||
|
|
@ -18,18 +18,7 @@ import {
|
|||
} from "@mui/material";
|
||||
import { VendorFormFields } from "./vendor-form-fields";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import type {
|
||||
Vendor,
|
||||
VendorFacetCompany,
|
||||
VendorListItem,
|
||||
VendorPreferredContact,
|
||||
} from "@/domain/vendors/types/vendor";
|
||||
|
||||
const PREFERRED_CONTACT_LABELS: Record<VendorPreferredContact, string> = {
|
||||
Phone: "Phone",
|
||||
Email: "Email",
|
||||
Text: "Text Message",
|
||||
};
|
||||
import type { Vendor, VendorFacetCompany, VendorListItem } from "@/domain/vendors/types/vendor";
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
|
|
@ -123,10 +112,6 @@ export function VendorDrawerView({ detail, mapsUrl, submitError, trades }: ViewP
|
|||
<DetailSection title="Technician">
|
||||
<DetailField label="Technician Name" value={detail.contactName} />
|
||||
<DetailField label="Technician Phone" value={detail.phone} />
|
||||
<DetailField
|
||||
label="Preferred Contact"
|
||||
value={PREFERRED_CONTACT_LABELS[detail.preferredContact ?? "Phone"]}
|
||||
/>
|
||||
</DetailSection>
|
||||
<DetailSection title="Company">
|
||||
<DetailField label="Company" value={detail.companyName} />
|
||||
|
|
|
|||
|
|
@ -13,15 +13,9 @@ import {
|
|||
FormLabel,
|
||||
Stack,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
VENDOR_PREFERRED_CONTACT_VALUES,
|
||||
type VendorFacetCompany,
|
||||
type VendorPreferredContact,
|
||||
} from "@/domain/vendors/types/vendor";
|
||||
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
|
||||
interface VendorFormFieldsProps {
|
||||
|
|
@ -33,6 +27,8 @@ interface VendorFormFieldsProps {
|
|||
showTotalJobs?: boolean;
|
||||
}
|
||||
|
||||
type VendorFieldErrors = FieldErrors<VendorFormValues>;
|
||||
|
||||
function splitTrades(value: string | undefined): string[] {
|
||||
return (value ?? "")
|
||||
.split(",")
|
||||
|
|
@ -52,11 +48,331 @@ function formatPhoneMask(input: string): string {
|
|||
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||
}
|
||||
|
||||
const PREFERRED_CONTACT_LABELS: Record<VendorPreferredContact, string> = {
|
||||
Phone: "Phone",
|
||||
Email: "Email",
|
||||
Text: "Text",
|
||||
};
|
||||
function TechnicianFields({
|
||||
control,
|
||||
errors,
|
||||
}: {
|
||||
control: Control<VendorFormValues>;
|
||||
errors: VendorFieldErrors;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||
Technician
|
||||
</Typography>
|
||||
<Stack spacing={2} className="mt-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="contactName"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Technician Name"
|
||||
required
|
||||
error={Boolean(errors.contactName)}
|
||||
helperText={errors.contactName?.message}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Technician Phone"
|
||||
placeholder="(XXX) XXX-XXXX"
|
||||
inputMode="tel"
|
||||
value={field.value ?? ""}
|
||||
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
|
||||
error={Boolean(errors.phone)}
|
||||
helperText={errors.phone?.message}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CompanyFields({
|
||||
control,
|
||||
errors,
|
||||
companies,
|
||||
onCompanySelected,
|
||||
}: {
|
||||
control: Control<VendorFormValues>;
|
||||
errors: VendorFieldErrors;
|
||||
companies: VendorFacetCompany[];
|
||||
onCompanySelected: (company: VendorFacetCompany | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||
Company
|
||||
</Typography>
|
||||
<Stack spacing={2} className="mt-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="companyName"
|
||||
render={({ field }) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="companyId"
|
||||
render={({ field: companyIdField }) => (
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={companies}
|
||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
typeof option === "string" || typeof value === "string"
|
||||
? option === value
|
||||
: option.name === value.name
|
||||
}
|
||||
value={field.value ?? ""}
|
||||
inputValue={field.value ?? ""}
|
||||
onInputChange={(_event, value, reason) => {
|
||||
if (reason === "input") {
|
||||
field.onChange(value);
|
||||
companyIdField.onChange(null);
|
||||
}
|
||||
}}
|
||||
onChange={(_event, value, reason) => {
|
||||
if (typeof value === "string") {
|
||||
field.onChange(value);
|
||||
companyIdField.onChange(null);
|
||||
return;
|
||||
}
|
||||
if (value) {
|
||||
field.onChange(value.name);
|
||||
companyIdField.onChange(value.companyId);
|
||||
if (reason === "selectOption") {
|
||||
onCompanySelected(value);
|
||||
}
|
||||
} else {
|
||||
field.onChange("");
|
||||
companyIdField.onChange(null);
|
||||
}
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Company"
|
||||
required
|
||||
placeholder="Select or type a company"
|
||||
error={Boolean(errors.companyName)}
|
||||
helperText={errors.companyName?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="companyPhone"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Company Phone"
|
||||
placeholder="(XXX) XXX-XXXX"
|
||||
inputMode="tel"
|
||||
value={field.value ?? ""}
|
||||
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
|
||||
error={Boolean(errors.companyPhone)}
|
||||
helperText={errors.companyPhone?.message}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Email"
|
||||
type="email"
|
||||
error={Boolean(errors.email)}
|
||||
helperText={errors.email?.message}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="address"
|
||||
render={({ field }) => <TextField {...field} label="Address" fullWidth />}
|
||||
/>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="city"
|
||||
render={({ field }) => <TextField {...field} label="City" fullWidth />}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="state"
|
||||
render={({ field }) => (
|
||||
<TextField {...field} label="State" placeholder="e.g., IL" fullWidth />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="zip"
|
||||
render={({ field }) => <TextField {...field} label="Zip" fullWidth />}
|
||||
/>
|
||||
</Stack>
|
||||
<Controller
|
||||
control={control}
|
||||
name="googleMapsUrl"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Google Maps URL"
|
||||
placeholder="https://maps.google.com/..."
|
||||
error={Boolean(errors.googleMapsUrl)}
|
||||
helperText={errors.googleMapsUrl?.message ?? "Must use HTTPS"}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TradeSpecialtiesField({
|
||||
control,
|
||||
tradeOptions,
|
||||
}: {
|
||||
control: Control<VendorFormValues>;
|
||||
tradeOptions: string[];
|
||||
}) {
|
||||
const [tradeInput, setTradeInput] = useState("");
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<FormLabel component="legend">Trade Specialties</FormLabel>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
First trade is primary. Reorder with the arrows.
|
||||
</Typography>
|
||||
<Controller
|
||||
control={control}
|
||||
name="tradeSpecialties"
|
||||
render={({ field }) => {
|
||||
const trades = splitTrades(field.value);
|
||||
const remove = (trade: string) => {
|
||||
field.onChange(joinTrades(trades.filter((entry) => entry !== trade)));
|
||||
};
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
const next = [...trades];
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= next.length) return;
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
field.onChange(joinTrades(next));
|
||||
};
|
||||
const add = (trade: string) => {
|
||||
const normalized = trade.trim();
|
||||
if (!normalized || trades.includes(normalized)) {
|
||||
setTradeInput("");
|
||||
return;
|
||||
}
|
||||
field.onChange(joinTrades([...trades, normalized]));
|
||||
setTradeInput("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="mt-2">
|
||||
<Box className="flex flex-wrap gap-1.5">
|
||||
{trades.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
No trades selected.
|
||||
</Typography>
|
||||
) : (
|
||||
trades.map((trade, index) => (
|
||||
<Chip
|
||||
key={`${trade}-${index}`}
|
||||
label={index === 0 ? `${trade} (primary)` : trade}
|
||||
onDelete={() => remove(trade)}
|
||||
deleteIcon={<CloseIcon data-testid={`remove-trade-${trade}`} />}
|
||||
sx={{ px: 0.5 }}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} className="mt-2" sx={{ alignItems: "center" }}>
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={tradeOptions}
|
||||
value={null}
|
||||
inputValue={tradeInput}
|
||||
onInputChange={(_event, value, reason) => {
|
||||
if (reason === "input") setTradeInput(value);
|
||||
}}
|
||||
onChange={(_event, value) => {
|
||||
if (typeof value === "string") add(value);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Add Trade"
|
||||
placeholder="Type or select a trade"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
add(tradeInput);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => add(tradeInput)}
|
||||
aria-label="Add trade"
|
||||
disabled={!tradeInput.trim()}
|
||||
>
|
||||
<AddIcon />
|
||||
</Button>
|
||||
</Stack>
|
||||
{trades.length > 1 && (
|
||||
<Stack direction="row" spacing={1} className="mt-2 flex-wrap">
|
||||
{trades.map((trade, index) => (
|
||||
<Stack key={`reorder-${trade}-${index}`} direction="row" spacing={0.5}>
|
||||
{index > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
aria-label={`Move ${trade} up`}
|
||||
onClick={() => move(index, -1)}
|
||||
>
|
||||
<ArrowUpwardIcon fontSize="small" />
|
||||
</Button>
|
||||
)}
|
||||
{index < trades.length - 1 && (
|
||||
<Button
|
||||
size="small"
|
||||
aria-label={`Move ${trade} down`}
|
||||
onClick={() => move(index, 1)}
|
||||
>
|
||||
<ArrowDownwardIcon fontSize="small" />
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function VendorFormFields({
|
||||
control,
|
||||
|
|
@ -66,330 +382,19 @@ export function VendorFormFields({
|
|||
onCompanySelected,
|
||||
showTotalJobs = false,
|
||||
}: VendorFormFieldsProps) {
|
||||
const [tradeInput, setTradeInput] = useState("");
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||
Technician
|
||||
</Typography>
|
||||
<Stack spacing={2} className="mt-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="contactName"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Technician Name"
|
||||
required
|
||||
error={Boolean(errors.contactName)}
|
||||
helperText={errors.contactName?.message}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Technician Phone"
|
||||
placeholder="(XXX) XXX-XXXX"
|
||||
inputMode="tel"
|
||||
value={field.value ?? ""}
|
||||
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
|
||||
error={Boolean(errors.phone)}
|
||||
helperText={errors.phone?.message}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Box>
|
||||
<FormLabel component="legend" sx={{ mb: 0.5 }}>
|
||||
Preferred Contact
|
||||
</FormLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name="preferredContact"
|
||||
render={({ field }) => (
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
size="small"
|
||||
value={field.value ?? "Phone"}
|
||||
onChange={(_event, value: VendorPreferredContact | null) => {
|
||||
if (value) field.onChange(value);
|
||||
}}
|
||||
aria-label="Preferred contact"
|
||||
>
|
||||
{VENDOR_PREFERRED_CONTACT_VALUES.map((option) => (
|
||||
<ToggleButton key={option} value={option} aria-label={option}>
|
||||
{PREFERRED_CONTACT_LABELS[option]}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<TechnicianFields control={control} errors={errors} />
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||
Company
|
||||
</Typography>
|
||||
<Stack spacing={2} className="mt-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="companyName"
|
||||
render={({ field }) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="companyId"
|
||||
render={({ field: companyIdField }) => (
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={companies}
|
||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
typeof option === "string" || typeof value === "string"
|
||||
? option === value
|
||||
: option.name === value.name
|
||||
}
|
||||
value={field.value ?? ""}
|
||||
inputValue={field.value ?? ""}
|
||||
onInputChange={(_event, value, reason) => {
|
||||
if (reason === "input") {
|
||||
field.onChange(value);
|
||||
companyIdField.onChange(null);
|
||||
}
|
||||
}}
|
||||
onChange={(_event, value, reason) => {
|
||||
if (typeof value === "string") {
|
||||
field.onChange(value);
|
||||
companyIdField.onChange(null);
|
||||
return;
|
||||
}
|
||||
if (value) {
|
||||
field.onChange(value.name);
|
||||
companyIdField.onChange(value.companyId);
|
||||
if (reason === "selectOption") {
|
||||
onCompanySelected(value);
|
||||
}
|
||||
} else {
|
||||
field.onChange("");
|
||||
companyIdField.onChange(null);
|
||||
}
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Company"
|
||||
required
|
||||
placeholder="Select or type a company"
|
||||
error={Boolean(errors.companyName)}
|
||||
helperText={errors.companyName?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="companyPhone"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Company Phone"
|
||||
placeholder="(XXX) XXX-XXXX"
|
||||
inputMode="tel"
|
||||
value={field.value ?? ""}
|
||||
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
|
||||
error={Boolean(errors.companyPhone)}
|
||||
helperText={errors.companyPhone?.message}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Email"
|
||||
type="email"
|
||||
error={Boolean(errors.email)}
|
||||
helperText={errors.email?.message}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="address"
|
||||
render={({ field }) => <TextField {...field} label="Address" fullWidth />}
|
||||
/>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="city"
|
||||
render={({ field }) => <TextField {...field} label="City" fullWidth />}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="state"
|
||||
render={({ field }) => (
|
||||
<TextField {...field} label="State" placeholder="e.g., IL" fullWidth />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="zip"
|
||||
render={({ field }) => <TextField {...field} label="Zip" fullWidth />}
|
||||
/>
|
||||
</Stack>
|
||||
<Controller
|
||||
control={control}
|
||||
name="googleMapsUrl"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Google Maps URL"
|
||||
placeholder="https://maps.google.com/..."
|
||||
error={Boolean(errors.googleMapsUrl)}
|
||||
helperText={errors.googleMapsUrl?.message ?? "Must use HTTPS"}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<CompanyFields
|
||||
control={control}
|
||||
errors={errors}
|
||||
companies={companies}
|
||||
onCompanySelected={onCompanySelected}
|
||||
/>
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<FormLabel component="legend">Trade Specialties</FormLabel>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
First trade is primary. Reorder with the arrows.
|
||||
</Typography>
|
||||
<Controller
|
||||
control={control}
|
||||
name="tradeSpecialties"
|
||||
render={({ field }) => {
|
||||
const trades = splitTrades(field.value);
|
||||
const remove = (trade: string) => {
|
||||
field.onChange(joinTrades(trades.filter((entry) => entry !== trade)));
|
||||
};
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
const next = [...trades];
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= next.length) return;
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
field.onChange(joinTrades(next));
|
||||
};
|
||||
const add = (trade: string) => {
|
||||
const normalized = trade.trim();
|
||||
if (!normalized || trades.includes(normalized)) {
|
||||
setTradeInput("");
|
||||
return;
|
||||
}
|
||||
field.onChange(joinTrades([...trades, normalized]));
|
||||
setTradeInput("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="mt-2">
|
||||
<Box className="flex flex-wrap gap-1.5">
|
||||
{trades.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
No trades selected.
|
||||
</Typography>
|
||||
) : (
|
||||
trades.map((trade, index) => (
|
||||
<Chip
|
||||
key={`${trade}-${index}`}
|
||||
label={index === 0 ? `${trade} (primary)` : trade}
|
||||
onDelete={() => remove(trade)}
|
||||
deleteIcon={<CloseIcon data-testid={`remove-trade-${trade}`} />}
|
||||
sx={{ px: 0.5 }}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} className="mt-2" sx={{ alignItems: "center" }}>
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={tradeOptions}
|
||||
value={null}
|
||||
inputValue={tradeInput}
|
||||
onInputChange={(_event, value, reason) => {
|
||||
if (reason === "input") setTradeInput(value);
|
||||
}}
|
||||
onChange={(_event, value) => {
|
||||
if (typeof value === "string") add(value);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Add Trade"
|
||||
placeholder="Type or select a trade"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
add(tradeInput);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => add(tradeInput)}
|
||||
aria-label="Add trade"
|
||||
disabled={!tradeInput.trim()}
|
||||
>
|
||||
<AddIcon />
|
||||
</Button>
|
||||
</Stack>
|
||||
{trades.length > 1 && (
|
||||
<Stack direction="row" spacing={1} className="mt-2 flex-wrap">
|
||||
{trades.map((trade, index) => (
|
||||
<Stack key={`reorder-${trade}-${index}`} direction="row" spacing={0.5}>
|
||||
<Button
|
||||
size="small"
|
||||
aria-label={`Move ${trade} up`}
|
||||
disabled={index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
>
|
||||
<ArrowUpwardIcon fontSize="small" />
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
aria-label={`Move ${trade} down`}
|
||||
disabled={index === trades.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
>
|
||||
<ArrowDownwardIcon fontSize="small" />
|
||||
</Button>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TradeSpecialtiesField control={control} tradeOptions={tradeOptions} />
|
||||
<Divider />
|
||||
|
||||
{showTotalJobs && (
|
||||
<Controller
|
||||
control={control}
|
||||
|
|
@ -407,7 +412,6 @@ export function VendorFormFields({
|
|||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="notes"
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ const defaultValues: VendorFormValues = {
|
|||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
companyId: null,
|
||||
preferredContact: "Phone",
|
||||
};
|
||||
|
||||
export default function VendorFormPage() {
|
||||
|
|
@ -86,7 +85,6 @@ export default function VendorFormPage() {
|
|||
tradeSpecialties: vendor.tradeSpecialties,
|
||||
isActive: vendor.isActive,
|
||||
companyId: vendor.companyId ?? null,
|
||||
preferredContact: vendor.preferredContact ?? "Phone",
|
||||
});
|
||||
}
|
||||
}, [vendor, isEdit, reset]);
|
||||
|
|
|
|||
|
|
@ -1,193 +1,23 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Link as RouterLink, useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Alert,
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Link,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { Alert, Box, CircularProgress } from "@mui/material";
|
||||
import { DispatchCreateModal } from "./_components/dispatch-create-modal";
|
||||
import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
|
||||
import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order";
|
||||
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
|
||||
import { VendorEditorDialog } from "./_components/vendor-editor-dialog";
|
||||
import { useVendorEditor } from "./_components/use-vendor-editor";
|
||||
import { WorkOrderActivity } from "./_components/work-order-activity";
|
||||
import {
|
||||
DispatchAccordion,
|
||||
WorkOrderDetailsSection,
|
||||
WorkOrderHeader,
|
||||
WorkOrderSummaryAccordions,
|
||||
} from "./_components/work-order-view-sections";
|
||||
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useAddWorkOrderComment,
|
||||
useChangeWorkOrderAssignment,
|
||||
useChangeWorkOrderStatus,
|
||||
useUpdateWorkOrderVendor,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||
import type { VendorPreferredContact } from "@/domain/vendors/types/vendor";
|
||||
import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||
|
||||
const STATUS_COLORS: Record<
|
||||
string,
|
||||
"default" | "primary" | "secondary" | "error" | "info" | "success" | "warning"
|
||||
> = {
|
||||
Open: "primary",
|
||||
"In Progress": "info",
|
||||
"On Hold": "warning",
|
||||
Done: "success",
|
||||
Cancelled: "default",
|
||||
};
|
||||
|
||||
type ActivityTab = "customer" | "internal" | "audit";
|
||||
|
||||
interface VendorEditorState {
|
||||
vendorId: string | number;
|
||||
vendorName: string;
|
||||
contactName: string;
|
||||
preferredContact: VendorPreferredContact;
|
||||
phone: string;
|
||||
email: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
function formatPhoneMask(input: string): string {
|
||||
const digits = input.replace(/\D/g, "").slice(0, 10);
|
||||
if (digits.length <= 3) return digits;
|
||||
if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
|
||||
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||
}
|
||||
|
||||
function isVendorEditorValid(editor: VendorEditorState): boolean {
|
||||
const phoneValid = editor.phone === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone);
|
||||
const emailValid = editor.email === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editor.email);
|
||||
return (
|
||||
phoneValid && emailValid && editor.notes.length <= 2000 && editor.contactName.length <= 200
|
||||
);
|
||||
}
|
||||
|
||||
function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) {
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...comments].sort(
|
||||
(a, b) => new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime(),
|
||||
),
|
||||
[comments],
|
||||
);
|
||||
|
||||
if (!sorted.length) {
|
||||
return (
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No comments yet.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2} className="max-h-72 overflow-y-auto">
|
||||
{sorted.map((comment) => (
|
||||
<Box key={String(comment.id)} className="rounded border border-border p-3">
|
||||
<Stack
|
||||
direction="row"
|
||||
className="mb-1"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" className="font-semibold">
|
||||
{comment.userName || comment.commenter || "System"}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{comment.createdDate ? formatDateTime(comment.createdDate) : ""}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2">{comment.commenttext}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...entries].sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
),
|
||||
[entries],
|
||||
);
|
||||
|
||||
if (!sorted.length) {
|
||||
return (
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No changes recorded.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2} className="max-h-72 overflow-y-auto">
|
||||
{sorted.map((entry) => (
|
||||
<Box key={String(entry.id)} className="rounded border border-border p-3">
|
||||
<Stack
|
||||
direction="row"
|
||||
className="mb-1"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" className="font-semibold">
|
||||
{entry.userName || "System"}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2">
|
||||
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkOrderViewPage() {
|
||||
const { id } = useParams();
|
||||
|
|
@ -198,111 +28,10 @@ export default function WorkOrderViewPage() {
|
|||
const { data: users = [] } = useWorkOrderUsers();
|
||||
const statusMutation = useChangeWorkOrderStatus(workOrderId);
|
||||
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
|
||||
const commentMutation = useAddWorkOrderComment(workOrderId);
|
||||
const vendorMutation = useUpdateWorkOrderVendor(workOrderId);
|
||||
const vendorEditor = useVendorEditor({ workOrderId, onSaved: () => void refetch() });
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [dispatchOpen, setDispatchOpen] = useState(false);
|
||||
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
|
||||
const [statusAnchor, setStatusAnchor] = useState<HTMLElement | null>(null);
|
||||
const [assignAnchor, setAssignAnchor] = useState<HTMLElement | null>(null);
|
||||
const [vendorEditor, setVendorEditor] = useState<VendorEditorState | null>(null);
|
||||
const [vendorEditorLoading, setVendorEditorLoading] = useState(false);
|
||||
const [vendorEditorError, setVendorEditorError] = useState("");
|
||||
|
||||
const customerComments = useMemo(
|
||||
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"),
|
||||
[wo?.comments],
|
||||
);
|
||||
const internalComments = useMemo(
|
||||
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "internal"),
|
||||
[wo?.comments],
|
||||
);
|
||||
const auditLog = wo?.auditLog ?? [];
|
||||
|
||||
const handleCommentSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!commentText.trim()) {
|
||||
return;
|
||||
}
|
||||
if (activeTab !== "customer" && activeTab !== "internal") {
|
||||
return;
|
||||
}
|
||||
|
||||
commentMutation.mutate(
|
||||
{ text: commentText.trim(), commentType: activeTab },
|
||||
{ onSuccess: () => setCommentText("") },
|
||||
);
|
||||
};
|
||||
|
||||
const openVendorEditor = async (
|
||||
event: React.MouseEvent,
|
||||
vendorId: string | number,
|
||||
vendorName: string,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
setVendorEditorError("");
|
||||
setVendorEditorLoading(true);
|
||||
setVendorEditor({
|
||||
vendorId,
|
||||
vendorName,
|
||||
contactName: "",
|
||||
preferredContact: "Phone",
|
||||
phone: "",
|
||||
email: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
try {
|
||||
const vendor = await vendorsApi.getById(vendorId);
|
||||
setVendorEditor({
|
||||
vendorId,
|
||||
vendorName: vendor.companyName || vendorName,
|
||||
contactName: vendor.contactName,
|
||||
preferredContact: vendor.preferredContact,
|
||||
phone: vendor.phone,
|
||||
email: vendor.email,
|
||||
notes: vendor.notes,
|
||||
});
|
||||
} catch (vendorError) {
|
||||
setVendorEditorError(
|
||||
vendorError instanceof Error ? vendorError.message : "Unable to load vendor details",
|
||||
);
|
||||
} finally {
|
||||
setVendorEditorLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveVendorEditor = () => {
|
||||
if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return;
|
||||
const numericWorkOrderId = Number(workOrderId);
|
||||
if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) {
|
||||
setVendorEditorError("A valid work order is required");
|
||||
return;
|
||||
}
|
||||
|
||||
vendorMutation.mutate(
|
||||
{
|
||||
vendorId: vendorEditor.vendorId,
|
||||
payload: {
|
||||
workOrderId: numericWorkOrderId,
|
||||
contactName: vendorEditor.contactName.trim(),
|
||||
preferredContact: vendorEditor.preferredContact,
|
||||
phone: vendorEditor.phone,
|
||||
email: vendorEditor.email.trim(),
|
||||
notes: vendorEditor.notes.trim(),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setVendorEditor(null);
|
||||
void refetch();
|
||||
},
|
||||
onError: (vendorError) => setVendorEditorError(vendorError.message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -324,333 +53,31 @@ export default function WorkOrderViewPage() {
|
|||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Breadcrumbs>
|
||||
<Link component={RouterLink} to="/workorders" underline="hover">
|
||||
Work Orders
|
||||
</Link>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.primary",
|
||||
}}
|
||||
>
|
||||
{wo.internalWONumber}
|
||||
</Typography>
|
||||
</Breadcrumbs>
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
alignItems: { xs: "stretch", md: "flex-start" },
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5" component="h1">
|
||||
{wo.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={wo.status}
|
||||
color={STATUS_COLORS[wo.status] ?? "default"}
|
||||
onClick={(event) => setStatusAnchor(event.currentTarget)}
|
||||
className="w-fit cursor-pointer"
|
||||
/>
|
||||
<Menu
|
||||
anchorEl={statusAnchor}
|
||||
open={Boolean(statusAnchor)}
|
||||
onClose={() => setStatusAnchor(null)}
|
||||
>
|
||||
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
|
||||
<MenuItem
|
||||
key={status}
|
||||
selected={status === wo.status}
|
||||
onClick={() => {
|
||||
setStatusAnchor(null);
|
||||
if (status !== wo.status) {
|
||||
statusMutation.mutate(status);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button variant="contained" color="success" onClick={() => setDispatchOpen(true)}>
|
||||
Dispatch
|
||||
</Button>
|
||||
<Button variant="contained" onClick={() => navigate(`/workorders/edit/${id}`)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => navigate("/workorders")}>
|
||||
Back to List
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Details
|
||||
</Typography>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Field label="Internal WO" value={wo.internalWONumber} />
|
||||
<Field label="Customer WO" value={wo.workOrderNumber || "—"} />
|
||||
<Field label="Priority" value={wo.priority || "—"} />
|
||||
<Field label="Location" value={wo.location || "—"} />
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
Assigned To
|
||||
</Typography>
|
||||
<Button size="small" onClick={(event) => setAssignAnchor(event.currentTarget)}>
|
||||
{wo.assignedTo || "Unassigned"}
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={assignAnchor}
|
||||
open={Boolean(assignAnchor)}
|
||||
onClose={() => setAssignAnchor(null)}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setAssignAnchor(null);
|
||||
assignmentMutation.mutate({ userId: "", userName: "" });
|
||||
}}
|
||||
>
|
||||
Unassigned
|
||||
</MenuItem>
|
||||
{users.map((user) => (
|
||||
<MenuItem
|
||||
key={String(user.id)}
|
||||
selected={wo.assignedTo === user.name}
|
||||
onClick={() => {
|
||||
setAssignAnchor(null);
|
||||
assignmentMutation.mutate({ userId: user.id, userName: user.name });
|
||||
}}
|
||||
>
|
||||
{user.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Box>
|
||||
<Field label="Source" value={wo.source || "—"} />
|
||||
<Field label="TT" value={wo.tt || "—"} />
|
||||
</Box>
|
||||
</Paper>
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Classification
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Field label="Problem" value={wo.problem || "—"} />
|
||||
<Field label="Trade" value={wo.trade || "—"} />
|
||||
<Field label="Sub-Trade" value={wo.subTrade || "—"} />
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Schedule
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field
|
||||
label="Created"
|
||||
value={wo.createdDateFull ? formatDateTime(wo.createdDateFull) : "—"}
|
||||
/>
|
||||
<Field label="Due Date" value={wo.dueDate || "—"} />
|
||||
<Field label="Scheduled" value={wo.scheduledDate || "—"} />
|
||||
<Field label="Completed" value={wo.completedDate || "—"} />
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Financial
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Field
|
||||
label="Vendor NTE"
|
||||
value={wo.vendorNTE ? `$${parseFloat(wo.vendorNTE).toFixed(2)}` : "—"}
|
||||
/>
|
||||
<Field label="Customer PO" value={wo.po || "—"} />
|
||||
<Field label="Customer NTE" value="Not connected" />
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Dispatches ({wo.dispatches.length})
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{wo.dispatches.length ? (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Dispatch #</TableCell>
|
||||
<TableCell>Vendor</TableCell>
|
||||
<TableCell>NTE</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Date</TableCell>
|
||||
<TableCell>Wait</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{wo.dispatches.map((dispatch) => {
|
||||
const waitColor = waitTimeColor(dispatch.dispatchedAt);
|
||||
return (
|
||||
<TableRow
|
||||
key={String(dispatch.id)}
|
||||
hover
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDispatchDetailId(dispatch.id)}
|
||||
>
|
||||
<TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell>
|
||||
<TableCell>{dispatch.vendorName}</TableCell>
|
||||
<TableCell>
|
||||
{dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
|
||||
</TableCell>
|
||||
<TableCell>{dispatch.status}</TableCell>
|
||||
<TableCell>
|
||||
{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{dispatch.status === "Sent" ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
className={
|
||||
waitColor === "green"
|
||||
? "text-green-600"
|
||||
: waitColor === "yellow"
|
||||
? "text-yellow-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{timeSince(dispatch.dispatchedAt)}
|
||||
</Typography>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={(event) =>
|
||||
void openVendorEditor(event, dispatch.vendorId, dispatch.vendorName)
|
||||
}
|
||||
disabled={dispatch.vendorId === ""}
|
||||
>
|
||||
Edit vendor
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No dispatches yet.
|
||||
</Typography>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Description
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography>{wo.description || "No description provided."}</Typography>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Activity
|
||||
</Typography>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_event, value: ActivityTab) => setActiveTab(value)}
|
||||
className="mb-3"
|
||||
>
|
||||
<Tab value="customer" label={`Customer (${customerComments.length})`} />
|
||||
<Tab value="internal" label={`Internal (${internalComments.length})`} />
|
||||
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
|
||||
</Tabs>
|
||||
|
||||
{activeTab === "customer" && <CommentTimeline comments={customerComments} />}
|
||||
{activeTab === "internal" && <CommentTimeline comments={internalComments} />}
|
||||
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
|
||||
|
||||
{activeTab === "internal" && (
|
||||
<Box component="form" onSubmit={handleCommentSubmit} className="mt-4 flex flex-col gap-2">
|
||||
<TextField
|
||||
multiline
|
||||
rows={2}
|
||||
value={commentText}
|
||||
onChange={(event) => setCommentText(event.target.value)}
|
||||
placeholder="Type a comment..."
|
||||
disabled={commentMutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
className="self-end"
|
||||
disabled={commentMutation.isPending || !commentText.trim()}
|
||||
>
|
||||
{commentMutation.isPending ? "Sending..." : "Send"}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
<WorkOrderHeader
|
||||
wo={wo}
|
||||
onSelectStatus={(status) => statusMutation.mutate(status)}
|
||||
onDispatch={() => setDispatchOpen(true)}
|
||||
onEdit={() => navigate(`/workorders/edit/${id}`)}
|
||||
onBack={() => navigate("/workorders")}
|
||||
/>
|
||||
<WorkOrderDetailsSection
|
||||
wo={wo}
|
||||
users={users}
|
||||
onAssign={(payload) => assignmentMutation.mutate(payload)}
|
||||
/>
|
||||
<WorkOrderSummaryAccordions wo={wo} />
|
||||
<DispatchAccordion
|
||||
wo={wo}
|
||||
onEditVendor={(event, vendorId, vendorName) =>
|
||||
void vendorEditor.open(event, vendorId, vendorName)
|
||||
}
|
||||
onViewDispatch={setDispatchDetailId}
|
||||
/>
|
||||
<WorkOrderActivity
|
||||
workOrderId={workOrderId}
|
||||
comments={wo.comments ?? []}
|
||||
auditLog={wo.auditLog ?? []}
|
||||
/>
|
||||
{dispatchOpen && (
|
||||
<DispatchCreateModal
|
||||
workOrder={wo}
|
||||
|
|
@ -666,132 +93,17 @@ export default function WorkOrderViewPage() {
|
|||
onUpdated={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
<Dialog
|
||||
open={vendorEditor !== null}
|
||||
onClose={() => {
|
||||
if (!vendorMutation.isPending) setVendorEditor(null);
|
||||
}}
|
||||
fullWidth
|
||||
maxWidth="sm"
|
||||
>
|
||||
<DialogTitle>Edit assigned vendor</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} className="pt-2">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{vendorEditor?.vendorName}
|
||||
</Typography>
|
||||
{Boolean(vendorEditorError) && <Alert severity="error">{vendorEditorError}</Alert>}
|
||||
{vendorEditorLoading ? (
|
||||
<Box className="flex justify-center p-6">
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<TextField
|
||||
label="Technician name"
|
||||
value={vendorEditor?.contactName ?? ""}
|
||||
onChange={(event) =>
|
||||
setVendorEditor((current) =>
|
||||
current ? { ...current, contactName: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
slotProps={{ htmlInput: { maxLength: 200 } }}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label="Preferred contact"
|
||||
value={vendorEditor?.preferredContact ?? "Phone"}
|
||||
onChange={(event) =>
|
||||
setVendorEditor((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
preferredContact: event.target.value as VendorPreferredContact,
|
||||
}
|
||||
: current,
|
||||
)
|
||||
}
|
||||
>
|
||||
<MenuItem value="Phone">Phone</MenuItem>
|
||||
<MenuItem value="Email">Email</MenuItem>
|
||||
<MenuItem value="Text">Text</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Phone"
|
||||
value={vendorEditor?.phone ?? ""}
|
||||
onChange={(event) =>
|
||||
setVendorEditor((current) =>
|
||||
current
|
||||
? { ...current, phone: formatPhoneMask(event.target.value) }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
error={Boolean(
|
||||
vendorEditor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(vendorEditor.phone),
|
||||
)}
|
||||
helperText="Use (XXX) XXX-XXXX"
|
||||
/>
|
||||
<TextField
|
||||
label="Email"
|
||||
type="email"
|
||||
value={vendorEditor?.email ?? ""}
|
||||
onChange={(event) =>
|
||||
setVendorEditor((current) =>
|
||||
current ? { ...current, email: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="Notes"
|
||||
multiline
|
||||
minRows={3}
|
||||
value={vendorEditor?.notes ?? ""}
|
||||
onChange={(event) =>
|
||||
setVendorEditor((current) =>
|
||||
current ? { ...current, notes: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
slotProps={{ htmlInput: { maxLength: 2000 } }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setVendorEditor(null)} disabled={vendorMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={saveVendorEditor}
|
||||
disabled={
|
||||
!vendorEditor ||
|
||||
vendorEditorLoading ||
|
||||
vendorMutation.isPending ||
|
||||
!isVendorEditorValid(vendorEditor)
|
||||
}
|
||||
>
|
||||
{vendorMutation.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2">{value}</Typography>
|
||||
<VendorEditorDialog
|
||||
editor={vendorEditor.editor}
|
||||
loading={vendorEditor.loading}
|
||||
error={vendorEditor.error}
|
||||
isPending={vendorEditor.isPending}
|
||||
phoneInvalid={vendorEditor.phoneInvalid}
|
||||
isValid={vendorEditor.isValid}
|
||||
onClose={vendorEditor.close}
|
||||
onSave={vendorEditor.save}
|
||||
onFieldChange={vendorEditor.updateField}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
151
src/app/(protected)/workorders/_components/use-vendor-editor.ts
Normal file
151
src/app/(protected)/workorders/_components/use-vendor-editor.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { useState } from "react";
|
||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||
import { useUpdateWorkOrderVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
|
||||
export interface VendorEditorState {
|
||||
vendorId: string | number;
|
||||
vendorName: string;
|
||||
contactName: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const PHONE_PATTERN = /^\(\d{3}\) \d{3}-\d{4}$/;
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const MAX_CONTACT_LENGTH = 200;
|
||||
const MAX_NOTES_LENGTH = 2000;
|
||||
|
||||
export function formatPhoneMask(input: string): string {
|
||||
const digits = input.replace(/\D/g, "").slice(0, 10);
|
||||
if (digits.length <= 3) return digits;
|
||||
if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
|
||||
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||
}
|
||||
|
||||
export function isVendorEditorValid(editor: VendorEditorState): boolean {
|
||||
const phoneValid = editor.phone === "" || PHONE_PATTERN.test(editor.phone);
|
||||
const emailValid = editor.email === "" || EMAIL_PATTERN.test(editor.email);
|
||||
return (
|
||||
phoneValid &&
|
||||
emailValid &&
|
||||
editor.notes.length <= MAX_NOTES_LENGTH &&
|
||||
editor.contactName.length <= MAX_CONTACT_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
interface UseVendorEditorOptions {
|
||||
workOrderId: string;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export interface UseVendorEditorResult {
|
||||
editor: VendorEditorState | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
isPending: boolean;
|
||||
phoneInvalid: boolean;
|
||||
isValid: boolean;
|
||||
open: (event: React.MouseEvent, vendorId: string | number, vendorName: string) => Promise<void>;
|
||||
close: () => void;
|
||||
save: () => void;
|
||||
updateField: (field: keyof VendorEditorState, value: string) => void;
|
||||
}
|
||||
|
||||
export function useVendorEditor({
|
||||
workOrderId,
|
||||
onSaved,
|
||||
}: UseVendorEditorOptions): UseVendorEditorResult {
|
||||
const vendorMutation = useUpdateWorkOrderVendor(workOrderId);
|
||||
const [editor, setEditor] = useState<VendorEditorState | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const open = async (event: React.MouseEvent, vendorId: string | number, vendorName: string) => {
|
||||
event.stopPropagation();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
setEditor({
|
||||
vendorId,
|
||||
vendorName,
|
||||
contactName: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
try {
|
||||
const vendor = await vendorsApi.getById(vendorId);
|
||||
setEditor({
|
||||
vendorId,
|
||||
vendorName: vendor.companyName || vendorName,
|
||||
contactName: vendor.contactName,
|
||||
phone: vendor.phone,
|
||||
email: vendor.email,
|
||||
notes: vendor.notes,
|
||||
});
|
||||
} catch (vendorError) {
|
||||
setError(
|
||||
vendorError instanceof Error ? vendorError.message : "Unable to load vendor details",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (!vendorMutation.isPending) {
|
||||
setEditor(null);
|
||||
}
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (!editor || !isVendorEditorValid(editor)) return;
|
||||
const numericWorkOrderId = Number(workOrderId);
|
||||
if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) {
|
||||
setError("A valid work order is required");
|
||||
return;
|
||||
}
|
||||
|
||||
vendorMutation.mutate(
|
||||
{
|
||||
vendorId: editor.vendorId,
|
||||
payload: {
|
||||
workOrderId: numericWorkOrderId,
|
||||
contactName: editor.contactName.trim(),
|
||||
phone: editor.phone,
|
||||
email: editor.email.trim(),
|
||||
notes: editor.notes.trim(),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditor(null);
|
||||
onSaved();
|
||||
},
|
||||
onError: (vendorError) => setError(vendorError.message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const updateField = (field: keyof VendorEditorState, value: string) => {
|
||||
setEditor((current) =>
|
||||
current
|
||||
? { ...current, [field]: field === "phone" ? formatPhoneMask(value) : value }
|
||||
: current,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
editor,
|
||||
loading,
|
||||
error,
|
||||
isPending: vendorMutation.isPending,
|
||||
phoneInvalid: Boolean(editor?.phone && !PHONE_PATTERN.test(editor.phone)),
|
||||
isValid: editor ? isVendorEditorValid(editor) : false,
|
||||
open,
|
||||
close,
|
||||
save,
|
||||
updateField,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type { VendorEditorState } from "./use-vendor-editor";
|
||||
|
||||
const MAX_CONTACT_LENGTH = 200;
|
||||
const MAX_NOTES_LENGTH = 2000;
|
||||
|
||||
interface VendorEditorDialogProps {
|
||||
editor: VendorEditorState | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
isPending: boolean;
|
||||
phoneInvalid: boolean;
|
||||
isValid: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onFieldChange: (field: keyof VendorEditorState, value: string) => void;
|
||||
}
|
||||
|
||||
export function VendorEditorDialog({
|
||||
editor,
|
||||
loading,
|
||||
error,
|
||||
isPending,
|
||||
phoneInvalid,
|
||||
isValid,
|
||||
onClose,
|
||||
onSave,
|
||||
onFieldChange,
|
||||
}: VendorEditorDialogProps) {
|
||||
return (
|
||||
<Dialog open={editor !== null} onClose={onClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Edit assigned vendor</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} className="pt-2">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{editor?.vendorName}
|
||||
</Typography>
|
||||
{Boolean(error) && <Alert severity="error">{error}</Alert>}
|
||||
{loading ? (
|
||||
<Box className="flex justify-center p-6">
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<TextField
|
||||
label="Technician name"
|
||||
value={editor?.contactName ?? ""}
|
||||
onChange={(event) => onFieldChange("contactName", event.target.value)}
|
||||
slotProps={{ htmlInput: { maxLength: MAX_CONTACT_LENGTH } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Phone"
|
||||
value={editor?.phone ?? ""}
|
||||
onChange={(event) => onFieldChange("phone", event.target.value)}
|
||||
error={phoneInvalid}
|
||||
helperText="Use (XXX) XXX-XXXX"
|
||||
/>
|
||||
<TextField
|
||||
label="Email"
|
||||
type="email"
|
||||
value={editor?.email ?? ""}
|
||||
onChange={(event) => onFieldChange("email", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Notes"
|
||||
multiline
|
||||
minRows={3}
|
||||
value={editor?.notes ?? ""}
|
||||
onChange={(event) => onFieldChange("notes", event.target.value)}
|
||||
slotProps={{ htmlInput: { maxLength: MAX_NOTES_LENGTH } }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onSave}
|
||||
disabled={!editor || loading || isPending || !isValid}
|
||||
>
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Box, Button, Paper, Stack, Tab, Tabs, TextField, Typography } from "@mui/material";
|
||||
import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order";
|
||||
import { useAddWorkOrderComment } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
|
||||
type ActivityTab = "customer" | "internal" | "audit";
|
||||
|
||||
function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) {
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...comments].sort(
|
||||
(a, b) => new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime(),
|
||||
),
|
||||
[comments],
|
||||
);
|
||||
|
||||
if (!sorted.length) {
|
||||
return (
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No comments yet.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2} className="max-h-72 overflow-y-auto">
|
||||
{sorted.map((comment) => (
|
||||
<Box key={String(comment.id)} className="rounded border border-border p-3">
|
||||
<Stack
|
||||
direction="row"
|
||||
className="mb-1"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" className="font-semibold">
|
||||
{comment.userName || comment.commenter || "System"}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{comment.createdDate ? formatDateTime(comment.createdDate) : ""}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2">{comment.commenttext}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...entries].sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
),
|
||||
[entries],
|
||||
);
|
||||
|
||||
if (!sorted.length) {
|
||||
return (
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No changes recorded.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2} className="max-h-72 overflow-y-auto">
|
||||
{sorted.map((entry) => (
|
||||
<Box key={String(entry.id)} className="rounded border border-border p-3">
|
||||
<Stack
|
||||
direction="row"
|
||||
className="mb-1"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" className="font-semibold">
|
||||
{entry.userName || "System"}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2">
|
||||
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkOrderActivityProps {
|
||||
workOrderId: string;
|
||||
comments: WorkOrderComment[];
|
||||
auditLog: WorkOrderAuditEntry[];
|
||||
}
|
||||
|
||||
export function WorkOrderActivity({ workOrderId, comments, auditLog }: WorkOrderActivityProps) {
|
||||
const commentMutation = useAddWorkOrderComment(workOrderId);
|
||||
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
|
||||
const [commentText, setCommentText] = useState("");
|
||||
|
||||
const customerComments = useMemo(
|
||||
() => comments.filter((comment) => comment.commentType === "customer"),
|
||||
[comments],
|
||||
);
|
||||
const internalComments = useMemo(
|
||||
() => comments.filter((comment) => comment.commentType === "internal"),
|
||||
[comments],
|
||||
);
|
||||
|
||||
const handleCommentSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!commentText.trim()) {
|
||||
return;
|
||||
}
|
||||
if (activeTab !== "customer" && activeTab !== "internal") {
|
||||
return;
|
||||
}
|
||||
|
||||
commentMutation.mutate(
|
||||
{ text: commentText.trim(), commentType: activeTab },
|
||||
{ onSuccess: () => setCommentText("") },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Activity
|
||||
</Typography>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_event, value: ActivityTab) => setActiveTab(value)}
|
||||
className="mb-3"
|
||||
>
|
||||
<Tab value="customer" label={`Customer (${customerComments.length})`} />
|
||||
<Tab value="internal" label={`Internal (${internalComments.length})`} />
|
||||
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
|
||||
</Tabs>
|
||||
|
||||
{activeTab === "customer" && <CommentTimeline comments={customerComments} />}
|
||||
{activeTab === "internal" && <CommentTimeline comments={internalComments} />}
|
||||
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
|
||||
|
||||
{activeTab === "internal" && (
|
||||
<Box component="form" onSubmit={handleCommentSubmit} className="mt-4 flex flex-col gap-2">
|
||||
<TextField
|
||||
multiline
|
||||
rows={2}
|
||||
value={commentText}
|
||||
onChange={(event) => setCommentText(event.target.value)}
|
||||
placeholder="Type a comment..."
|
||||
disabled={commentMutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
className="self-end"
|
||||
disabled={commentMutation.isPending || !commentText.trim()}
|
||||
>
|
||||
{commentMutation.isPending ? "Sending..." : "Send"}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,401 @@
|
|||
import { useState } from "react";
|
||||
import { Link as RouterLink } from "react-router";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Chip,
|
||||
Link,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import type {
|
||||
WorkOrderDetail,
|
||||
WorkOrderStatus,
|
||||
WorkOrderUser,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
|
||||
import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||
|
||||
const STATUS_COLORS: Record<
|
||||
string,
|
||||
"default" | "primary" | "secondary" | "error" | "info" | "success" | "warning"
|
||||
> = {
|
||||
Open: "primary",
|
||||
"In Progress": "info",
|
||||
"On Hold": "warning",
|
||||
Done: "success",
|
||||
Cancelled: "default",
|
||||
};
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2">{value}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkOrderHeaderProps {
|
||||
wo: WorkOrderDetail;
|
||||
onSelectStatus: (status: WorkOrderStatus) => void;
|
||||
onDispatch: () => void;
|
||||
onEdit: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function WorkOrderHeader({
|
||||
wo,
|
||||
onSelectStatus,
|
||||
onDispatch,
|
||||
onEdit,
|
||||
onBack,
|
||||
}: WorkOrderHeaderProps) {
|
||||
const [statusAnchor, setStatusAnchor] = useState<HTMLElement | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs>
|
||||
<Link component={RouterLink} to="/workorders" underline="hover">
|
||||
Work Orders
|
||||
</Link>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.primary",
|
||||
}}
|
||||
>
|
||||
{wo.internalWONumber}
|
||||
</Typography>
|
||||
</Breadcrumbs>
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
alignItems: { xs: "stretch", md: "flex-start" },
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5" component="h1">
|
||||
{wo.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={wo.status}
|
||||
color={STATUS_COLORS[wo.status] ?? "default"}
|
||||
onClick={(event) => setStatusAnchor(event.currentTarget)}
|
||||
className="w-fit cursor-pointer"
|
||||
/>
|
||||
<Menu
|
||||
anchorEl={statusAnchor}
|
||||
open={Boolean(statusAnchor)}
|
||||
onClose={() => setStatusAnchor(null)}
|
||||
>
|
||||
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
|
||||
<MenuItem
|
||||
key={status}
|
||||
selected={status === wo.status}
|
||||
onClick={() => {
|
||||
setStatusAnchor(null);
|
||||
if (status !== wo.status) {
|
||||
onSelectStatus(status);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button variant="contained" color="success" onClick={onDispatch}>
|
||||
Dispatch
|
||||
</Button>
|
||||
<Button variant="contained" onClick={onEdit}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={onBack}>
|
||||
Back to List
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkOrderDetailsSectionProps {
|
||||
wo: WorkOrderDetail;
|
||||
users: WorkOrderUser[];
|
||||
onAssign: (payload: { userId: string | number; userName: string }) => void;
|
||||
}
|
||||
|
||||
export function WorkOrderDetailsSection({ wo, users, onAssign }: WorkOrderDetailsSectionProps) {
|
||||
const [assignAnchor, setAssignAnchor] = useState<HTMLElement | null>(null);
|
||||
|
||||
return (
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Details
|
||||
</Typography>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Field label="Internal WO" value={wo.internalWONumber} />
|
||||
<Field label="Customer WO" value={wo.workOrderNumber || "—"} />
|
||||
<Field label="Priority" value={wo.priority || "—"} />
|
||||
<Field label="Location" value={wo.location || "—"} />
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
Assigned To
|
||||
</Typography>
|
||||
<Button size="small" onClick={(event) => setAssignAnchor(event.currentTarget)}>
|
||||
{wo.assignedTo || "Unassigned"}
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={assignAnchor}
|
||||
open={Boolean(assignAnchor)}
|
||||
onClose={() => setAssignAnchor(null)}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setAssignAnchor(null);
|
||||
onAssign({ userId: "", userName: "" });
|
||||
}}
|
||||
>
|
||||
Unassigned
|
||||
</MenuItem>
|
||||
{users.map((user) => (
|
||||
<MenuItem
|
||||
key={String(user.id)}
|
||||
selected={wo.assignedTo === user.name}
|
||||
onClick={() => {
|
||||
setAssignAnchor(null);
|
||||
onAssign({ userId: user.id, userName: user.name });
|
||||
}}
|
||||
>
|
||||
{user.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Box>
|
||||
<Field label="Source" value={wo.source || "—"} />
|
||||
<Field label="TT" value={wo.tt || "—"} />
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkOrderSummaryAccordions({ wo }: { wo: WorkOrderDetail }) {
|
||||
return (
|
||||
<>
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Classification
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Field label="Problem" value={wo.problem || "—"} />
|
||||
<Field label="Trade" value={wo.trade || "—"} />
|
||||
<Field label="Sub-Trade" value={wo.subTrade || "—"} />
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Schedule
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field
|
||||
label="Created"
|
||||
value={wo.createdDateFull ? formatDateTime(wo.createdDateFull) : "—"}
|
||||
/>
|
||||
<Field label="Due Date" value={wo.dueDate || "—"} />
|
||||
<Field label="Scheduled" value={wo.scheduledDate || "—"} />
|
||||
<Field label="Completed" value={wo.completedDate || "—"} />
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Financial
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Field
|
||||
label="Vendor NTE"
|
||||
value={wo.vendorNTE ? `$${parseFloat(wo.vendorNTE).toFixed(2)}` : "—"}
|
||||
/>
|
||||
<Field label="Customer PO" value={wo.po || "—"} />
|
||||
<Field label="Customer NTE" value="Not connected" />
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Description
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography>{wo.description || "No description provided."}</Typography>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface DispatchAccordionProps {
|
||||
wo: WorkOrderDetail;
|
||||
onEditVendor: (event: React.MouseEvent, vendorId: string | number, vendorName: string) => void;
|
||||
onViewDispatch: (dispatchId: string | number) => void;
|
||||
}
|
||||
|
||||
export function DispatchAccordion({ wo, onEditVendor, onViewDispatch }: DispatchAccordionProps) {
|
||||
return (
|
||||
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Dispatches ({wo.dispatches.length})
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{wo.dispatches.length ? (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Dispatch #</TableCell>
|
||||
<TableCell>Vendor</TableCell>
|
||||
<TableCell>NTE</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Date</TableCell>
|
||||
<TableCell>Wait</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{wo.dispatches.map((dispatch) => {
|
||||
const waitColor = waitTimeColor(dispatch.dispatchedAt);
|
||||
return (
|
||||
<TableRow
|
||||
key={String(dispatch.id)}
|
||||
hover
|
||||
className="cursor-pointer"
|
||||
onClick={() => onViewDispatch(dispatch.id)}
|
||||
>
|
||||
<TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell>
|
||||
<TableCell>{dispatch.vendorName}</TableCell>
|
||||
<TableCell>
|
||||
{dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
|
||||
</TableCell>
|
||||
<TableCell>{dispatch.status}</TableCell>
|
||||
<TableCell>
|
||||
{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{dispatch.status === "Sent" ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
className={
|
||||
waitColor === "green"
|
||||
? "text-green-600"
|
||||
: waitColor === "yellow"
|
||||
? "text-yellow-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{timeSince(dispatch.dispatchedAt)}
|
||||
</Typography>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={(event) =>
|
||||
onEditVendor(event, dispatch.vendorId, dispatch.vendorName)
|
||||
}
|
||||
disabled={dispatch.vendorId === ""}
|
||||
>
|
||||
Edit vendor
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No dispatches yet.
|
||||
</Typography>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
13
src/domain/vendors/mappers/vendor-mapper.ts
vendored
13
src/domain/vendors/mappers/vendor-mapper.ts
vendored
|
|
@ -79,6 +79,14 @@ function readPreferredContact(
|
|||
return "Phone";
|
||||
}
|
||||
|
||||
function readPreferredContactOptional(
|
||||
record: Record<string, unknown>,
|
||||
): VendorPreferredContact | undefined {
|
||||
const raw = record.preferredContact ?? record.PreferredContact;
|
||||
if (typeof raw !== "string" || raw.trim() === "") return undefined;
|
||||
return readPreferredContact(record, "preferredContact", "PreferredContact");
|
||||
}
|
||||
|
||||
export function toCanonicalPhone(value: unknown): string {
|
||||
const original = String(value ?? "");
|
||||
let digits = original.replace(/\D+/g, "");
|
||||
|
|
@ -163,8 +171,11 @@ export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
|
|||
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
|
||||
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||
isActive: readBool(item, "isActive", "IsActive"),
|
||||
preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"),
|
||||
};
|
||||
const preferredContact = readPreferredContactOptional(item);
|
||||
if (preferredContact) {
|
||||
payload.preferredContact = preferredContact;
|
||||
}
|
||||
const id = item.id ?? item.Id;
|
||||
if (typeof id === "string" || typeof id === "number") {
|
||||
payload.id = id;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ export interface WorkOrderDispatchSummary {
|
|||
export interface WorkOrderVendorUpdatePayload {
|
||||
workOrderId: number;
|
||||
contactName?: string;
|
||||
preferredContact: "Phone" | "Email" | "Text";
|
||||
phone?: string;
|
||||
email?: string;
|
||||
notes?: string;
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ const emptyForm: VendorFormValues = {
|
|||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
companyId: null,
|
||||
preferredContact: "Phone",
|
||||
};
|
||||
|
||||
function Harness({
|
||||
|
|
@ -192,14 +191,4 @@ describe("VendorFormFields company autocomplete state", () => {
|
|||
await userEvent.type(companyPhone, "3145550100");
|
||||
expect(companyPhone).toHaveValue("(314) 555-0100");
|
||||
});
|
||||
|
||||
it("exposes a preferred contact selector with Phone, Email, and Text options", () => {
|
||||
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "Phone" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Email" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Text" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -141,7 +141,6 @@ describe("vendorsApi.getList", () => {
|
|||
notes: "Preferred after-hours vendor",
|
||||
totalJobs: 72,
|
||||
isActive: true,
|
||||
preferredContact: "Text",
|
||||
};
|
||||
apiPost.mockResolvedValueOnce({ data: values });
|
||||
apiPut.mockResolvedValueOnce({ data: { ...values, isActive: false } });
|
||||
|
|
@ -155,15 +154,16 @@ describe("vendorsApi.getList", () => {
|
|||
companyName: "Gateway Plumbing",
|
||||
notes: values.notes,
|
||||
companyId: 42,
|
||||
preferredContact: "Text",
|
||||
phone: "(314) 555-0198",
|
||||
companyPhone: "(314) 555-0100",
|
||||
}),
|
||||
);
|
||||
expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("preferredContact");
|
||||
expect(apiPut).toHaveBeenCalledWith(
|
||||
`${API_PATHS.rest.vendors}/7`,
|
||||
expect.objectContaining({ companyName: "Gateway Plumbing", isActive: false }),
|
||||
);
|
||||
expect(apiPut.mock.calls[0]?.[1]).not.toHaveProperty("preferredContact");
|
||||
expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs");
|
||||
expect(apiPut.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs");
|
||||
});
|
||||
|
|
@ -173,7 +173,6 @@ describe("vendorsApi.getList", () => {
|
|||
await vendorsApi.create({
|
||||
companyName: "Independent Vendor LLC",
|
||||
contactName: "Indy Tech",
|
||||
preferredContact: "Phone",
|
||||
});
|
||||
|
||||
expect(apiPost).toHaveBeenCalledWith(
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ describe("workOrdersApi.getById", () => {
|
|||
const payload = {
|
||||
workOrderId: 10,
|
||||
contactName: "Taylor Tech",
|
||||
preferredContact: "Text" as const,
|
||||
phone: "(314) 555-0199",
|
||||
email: "taylor@example.com",
|
||||
notes: "Text before arrival",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue