mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-01 00:23:12 +00:00
chore(work-orders): merge origin/dev and keep SH-114 closability gate
This commit is contained in:
commit
bc97f517f9
101 changed files with 4504 additions and 1510 deletions
114
e2e/vendors/vendors.spec.ts
vendored
114
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -306,11 +306,18 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
const state = await mockVendorApi(page);
|
||||
await page.goto("/vendors");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible();
|
||||
await expect(page.getByText("3 vendor entries · 2 active")).toBeVisible();
|
||||
const heading = page.getByRole("heading", { name: "Vendors / Technicians" });
|
||||
await expect(heading).toBeVisible();
|
||||
await expect(heading).toHaveCSS("font-family", /Montserrat/);
|
||||
await expect(heading).toHaveCSS("font-size", "30px");
|
||||
await expect(heading).toHaveCSS("font-weight", "800");
|
||||
await expect(heading).toHaveCSS("line-height", "33px");
|
||||
await expect(heading).toHaveCSS("letter-spacing", "-0.6px");
|
||||
await expect(page.getByText("3 technicians · 2 active")).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
|
|
@ -323,52 +330,58 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
"Phone",
|
||||
"Email",
|
||||
"Total Jobs",
|
||||
"Status",
|
||||
]) {
|
||||
await expect(page.getByRole("columnheader", { name: heading })).toBeVisible();
|
||||
}
|
||||
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
|
||||
const activeTabBox = await page.getByRole("tab", { name: "Active (2)" }).boundingBox();
|
||||
expect(activeTabBox).toMatchObject({ x: 256, y: 159.5, height: 36 });
|
||||
const tableContainerBox = await page.locator("table").locator("..").boundingBox();
|
||||
expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 });
|
||||
expect(tableContainerBox?.height).toBeCloseTo(738, 0);
|
||||
expect((await page.locator("table").boundingBox())?.width).toBe(1550);
|
||||
expect((await page.getByRole("columnheader", { name: "Company" }).boundingBox())?.height).toBe(
|
||||
47,
|
||||
);
|
||||
expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52);
|
||||
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
|
||||
await expect(page.getByText("+1")).toBeVisible();
|
||||
await expect(page.getByText("2 active vendor entries · 2 active · 1 inactive")).toBeVisible();
|
||||
await expect(page.getByText("2 active technicians")).toBeVisible();
|
||||
await expect(page.getByText("2 active · 1 inactive")).toBeVisible();
|
||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png");
|
||||
|
||||
await page.getByPlaceholder("Search name, company, trade...").fill("metro");
|
||||
await page.getByPlaceholder("Search name, company, trade…").fill("metro");
|
||||
await expect(page.getByText("Metro HVAC")).toBeVisible();
|
||||
await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible();
|
||||
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
|
||||
expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await page.getByPlaceholder("Search name, company, trade...").fill("clayton");
|
||||
await page.getByPlaceholder("Search name, company, trade…").fill("clayton");
|
||||
await expect(page.getByText("Metro HVAC")).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder("Search name, company, trade...").clear();
|
||||
await page.getByPlaceholder("Search name, company, trade…").clear();
|
||||
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
|
||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png");
|
||||
await page.getByRole("button", { name: /Company/ }).click();
|
||||
await page.getByPlaceholder("All companies").click();
|
||||
await page.getByRole("option", { name: /Gateway Plumbing/ }).click();
|
||||
await page.getByRole("button", { name: "Apply" }).click();
|
||||
await page.getByRole("button", { name: "Apply filters" }).click();
|
||||
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||||
await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible();
|
||||
await expect(page.getByText("1 technician · filtered by")).toBeVisible();
|
||||
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
|
||||
expect(
|
||||
state.listUrls.some(
|
||||
(url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||
await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText(
|
||||
"history will be preserved",
|
||||
);
|
||||
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
|
||||
await expect.poll(() => state.deletedId).toBe("1");
|
||||
|
||||
await page.getByRole("tab", { name: "Inactive (1)" }).click();
|
||||
await expect(page.getByText("No vendors match the current filters.")).toBeVisible();
|
||||
await expect(page.getByText("No inactive technicians found")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Clear all" }).click();
|
||||
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
|
||||
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
|
|
@ -378,33 +391,35 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
const state = await mockVendorApi(page);
|
||||
await page.goto("/vendors");
|
||||
|
||||
await page.getByRole("button", { name: "+ Add Vendor" }).click();
|
||||
await expect(
|
||||
page.getByText("Register a company and add zero, one, or multiple technicians."),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Add Vendor", exact: true }).click();
|
||||
await expect(page.getByText("Register a vendor company and its technicians.")).toBeVisible();
|
||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
|
||||
await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
|
||||
).toBeEnabled();
|
||||
|
||||
await page.getByRole("combobox", { name: "Company" }).click();
|
||||
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
|
||||
await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100");
|
||||
await expect(page.getByRole("textbox", { name: "Email", exact: true })).toHaveValue(
|
||||
await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100");
|
||||
await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
|
||||
"dispatch@gateway.test",
|
||||
);
|
||||
await expect(page.getByLabel("Address")).toHaveValue("1 Market St");
|
||||
await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway");
|
||||
await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St");
|
||||
await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue(
|
||||
"https://maps.google.com/gateway",
|
||||
);
|
||||
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Add technician" }).click();
|
||||
await page.getByLabel("Technician Name").last().fill("New Technician");
|
||||
await page.getByLabel("Technician name (optional)").last().fill("New Technician");
|
||||
const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last();
|
||||
await tradeInput.fill("HVAC");
|
||||
await tradeInput.press("Enter");
|
||||
await tradeInput.fill("Plumbing");
|
||||
await tradeInput.press("Enter");
|
||||
await page.getByRole("button", { name: "Move Plumbing up" }).click();
|
||||
await page.getByLabel("Notes").fill("Created in browser E2E");
|
||||
await page.getByRole("button", { name: "Add Vendor Company" }).last().click();
|
||||
await expect(page.getByRole("dialog", { name: "Add Vendor Company" })).toHaveCount(0);
|
||||
await page.getByLabel("Notes (optional)").fill("Created in browser E2E");
|
||||
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
|
||||
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
|
||||
expect(state.updatedBody).toMatchObject({
|
||||
name: "Gateway Plumbing",
|
||||
companyPhone: "(314) 555-0100",
|
||||
|
|
@ -429,7 +444,7 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
|
||||
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
||||
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||||
await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).toBeVisible();
|
||||
await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible();
|
||||
await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible();
|
||||
await expect(detailDrawer.getByText("72")).toBeVisible();
|
||||
await expect(
|
||||
|
|
@ -438,9 +453,9 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png");
|
||||
|
||||
await detailDrawer.getByRole("button", { name: "Edit" }).click();
|
||||
await expect(page).toHaveURL(/\/vendors\/edit\/1$/);
|
||||
await page.getByLabel("Notes").fill("Updated in browser E2E");
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await expect(page).toHaveURL(/\/vendors$/);
|
||||
await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E");
|
||||
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
|
||||
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
|
||||
});
|
||||
|
||||
|
|
@ -448,18 +463,18 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
const state = await mockVendorApi(page, { legacyGateway: true });
|
||||
await page.goto("/vendors");
|
||||
|
||||
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||||
await page.getByRole("switch", { name: "Active status" }).click();
|
||||
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
|
||||
await expect.poll(() => state.deletedId).toBe("1");
|
||||
|
||||
await page.getByRole("tab", { name: "Inactive (2)" }).click();
|
||||
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
||||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||||
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||||
await detailDrawer.getByRole("button", { name: "Edit" }).click();
|
||||
await expect(page).toHaveURL(/\/vendors\/edit\/1$/);
|
||||
await page.getByRole("switch", { name: "Inactive" }).first().click();
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await detailDrawer.getByRole("switch", { name: "Active status" }).click();
|
||||
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
|
||||
await expect.poll(() => state.updatedBody).toEqual({ isActive: true });
|
||||
await detailDrawer.getByRole("button", { name: "Close drawer" }).click();
|
||||
await page.getByRole("tab", { name: "Active (2)", exact: true }).click();
|
||||
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||||
});
|
||||
|
|
@ -468,7 +483,7 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
const state = await mockVendorApi(page);
|
||||
await page.goto("/vendors");
|
||||
|
||||
const addButton = page.getByRole("button", { name: "+ Add Vendor" });
|
||||
const addButton = page.getByRole("button", { name: "Add Vendor", exact: true });
|
||||
await addButton.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
|
||||
|
|
@ -477,10 +492,12 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
await expect(addButton).toBeFocused();
|
||||
|
||||
await addButton.click();
|
||||
await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
|
||||
).toBeEnabled();
|
||||
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
|
||||
await page.getByLabel("Company Phone").fill("3145550199");
|
||||
await page.getByRole("button", { name: "Add Vendor Company" }).last().click();
|
||||
await page.getByLabel("Company Phone (optional)").fill("3145550199");
|
||||
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
|
||||
|
||||
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");
|
||||
expect(state.createdBody?.technicians).toEqual([]);
|
||||
|
|
@ -506,7 +523,8 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
const blockedState = await mockVendorApi(page, { deactivationBlocked: true });
|
||||
await page.goto("/vendors");
|
||||
|
||||
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||||
await page.getByRole("switch", { name: "Active status" }).click();
|
||||
const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" });
|
||||
await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair");
|
||||
await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
|
||||
|
|
@ -517,7 +535,8 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
|
||||
const racedState = await mockVendorApi(page, { deleteConflict: true });
|
||||
await page.reload();
|
||||
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||||
await page.getByRole("switch", { name: "Active status" }).click();
|
||||
await page
|
||||
.getByRole("dialog", { name: "Deactivate Vendor" })
|
||||
.getByRole("button", {
|
||||
|
|
@ -555,6 +574,7 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
/Vendor directory unavailable|failed|server error/i,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile-list.png");
|
||||
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||||
const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" });
|
||||
await expect(filterDrawer).toBeVisible();
|
||||
|
|
|
|||
|
|
@ -2,11 +2,5 @@
|
|||
"version": 1,
|
||||
"purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.",
|
||||
"maxFileLines": 500,
|
||||
"godfileDebt": [
|
||||
{
|
||||
"path": "src/app/(protected)/vendors/index.tsx",
|
||||
"maxLines": 612,
|
||||
"reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction."
|
||||
}
|
||||
]
|
||||
"godfileDebt": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,7 @@ export interface VendorDeactivationState {
|
|||
confirm: () => void;
|
||||
}
|
||||
|
||||
export function useVendorDeactivation(options?: {
|
||||
onSuccess?: () => void;
|
||||
}): VendorDeactivationState {
|
||||
export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivationState {
|
||||
const [target, setTarget] = useState<VendorListItem | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const deleteVendor = useDeleteVendor();
|
||||
|
|
@ -43,7 +41,7 @@ export function useVendorDeactivation(options?: {
|
|||
deleteVendor.mutate(target.id, {
|
||||
onSuccess: () => {
|
||||
setTarget(null);
|
||||
options?.onSuccess?.();
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export interface VendorListState {
|
|||
export function useVendorListState(): VendorListState {
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(12);
|
||||
const [pageSize, setPageSize] = useState(48);
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [appliedFilters, setAppliedFilters] = useState<VendorFilters>(emptyVendorFilters);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react";
|
|||
import { useForm, useWatch, type FieldErrors } from "react-hook-form";
|
||||
import {
|
||||
emptyVendorCompanyRosterForm,
|
||||
emptyRosterTechnician,
|
||||
vendorCompanyRosterSchema,
|
||||
type VendorCompanyRosterFormValues,
|
||||
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||
|
|
@ -43,6 +44,7 @@ export interface VendorRosterFormProps {
|
|||
vendorId?: string | number;
|
||||
companyId?: string | number;
|
||||
onSuccess?: (roster: VendorCompanyRoster) => void;
|
||||
startWithTechnician?: boolean;
|
||||
}
|
||||
|
||||
export interface VendorRosterForm {
|
||||
|
|
@ -73,6 +75,7 @@ export function useVendorRosterForm({
|
|||
vendorId,
|
||||
companyId,
|
||||
onSuccess,
|
||||
startWithTechnician = false,
|
||||
}: VendorRosterFormProps): VendorRosterForm {
|
||||
const query = useVendorCompanyRoster(
|
||||
mode === "update" ? vendorId : undefined,
|
||||
|
|
@ -87,9 +90,16 @@ export function useVendorRosterForm({
|
|||
const trades = useMemo(() => facets?.trades ?? [], [facets]);
|
||||
const resolver = useVendorRosterResolver(routeRoster);
|
||||
|
||||
const createDefaults = useMemo(
|
||||
() => ({
|
||||
...emptyVendorCompanyRosterForm,
|
||||
technicians: startWithTechnician ? [{ ...emptyRosterTechnician }] : [],
|
||||
}),
|
||||
[startWithTechnician],
|
||||
);
|
||||
const form = useForm<VendorCompanyRosterFormValues>({
|
||||
resolver,
|
||||
defaultValues: emptyVendorCompanyRosterForm,
|
||||
defaultValues: createDefaults,
|
||||
values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined,
|
||||
mode: "onChange",
|
||||
});
|
||||
|
|
@ -114,10 +124,10 @@ export function useVendorRosterForm({
|
|||
}, [routeRoster, watched]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
reset(emptyVendorCompanyRosterForm);
|
||||
reset(createDefaults);
|
||||
resetSelection();
|
||||
clearConflict();
|
||||
}, [clearConflict, reset, resetSelection]);
|
||||
}, [clearConflict, createDefaults, reset, resetSelection]);
|
||||
|
||||
const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster;
|
||||
const isUpdate = mode === "update" || selection.selectedRoster != null;
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import {
|
|||
DialogContent,
|
||||
DialogTitle,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
|
||||
import { VendorRosterFormFields } from "./vendor-roster-form-fields";
|
||||
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
|
||||
|
|
@ -21,7 +21,11 @@ interface VendorCreateModalProps {
|
|||
}
|
||||
|
||||
export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
|
||||
const form = useVendorRosterForm({ mode: "create", onSuccess: onClose });
|
||||
const form = useVendorRosterForm({
|
||||
mode: "create",
|
||||
onSuccess: onClose,
|
||||
startWithTechnician: true,
|
||||
});
|
||||
const { resetForm } = form;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -33,17 +37,24 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
|
|||
open={open}
|
||||
onClose={form.isSaving ? undefined : onClose}
|
||||
fullWidth
|
||||
maxWidth="md"
|
||||
maxWidth="sm"
|
||||
aria-labelledby="vendor-create-dialog-title"
|
||||
slotProps={{ paper: { role: "dialog" } }}
|
||||
slotProps={{
|
||||
paper: {
|
||||
role: "dialog",
|
||||
sx: { maxHeight: "88vh", borderRadius: 2 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle id="vendor-create-dialog-title">
|
||||
Add Vendor Company
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
|
||||
Register a company and add zero, one, or multiple technicians.
|
||||
</Typography>
|
||||
<Text variant="title" as="span">
|
||||
Add Vendor
|
||||
</Text>
|
||||
<Text variant="description" tone="muted" sx={{ mt: 0.5 }}>
|
||||
Register a vendor company and its technicians.
|
||||
</Text>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContent sx={{ maxHeight: "55vh" }}>
|
||||
<Stack spacing={2} className="mt-1">
|
||||
{form.loadErrorMessage !== null && (
|
||||
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />
|
||||
|
|
@ -73,9 +84,9 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
|
|||
variant="contained"
|
||||
type="submit"
|
||||
form="vendor-create-form"
|
||||
disabled={form.isSaving || !form.isValid}
|
||||
disabled={form.isSaving}
|
||||
>
|
||||
{form.isSaving ? <CircularProgress size={20} color="inherit" /> : "Add Vendor Company"}
|
||||
{form.isSaving ? <CircularProgress size={20} color="inherit" /> : "Add Vendor"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Controller, 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";
|
||||
|
|
@ -13,15 +13,22 @@ import {
|
|||
Drawer,
|
||||
IconButton,
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
Stack,
|
||||
Typography,
|
||||
Switch,
|
||||
} from "@mui/material";
|
||||
import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
|
||||
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";
|
||||
import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor";
|
||||
|
||||
const DRAWER_WIDTH = 440;
|
||||
const DRAWER_WIDTH = 420;
|
||||
|
||||
function splitTrades(value: string | undefined): string[] {
|
||||
return (value ?? "")
|
||||
|
|
@ -33,106 +40,45 @@ function splitTrades(value: string | undefined): string[] {
|
|||
interface VendorDetailDrawerProps {
|
||||
open: boolean;
|
||||
vendor: VendorListItem | null;
|
||||
startInEdit?: boolean;
|
||||
onRequestDeactivation?: (vendor: VendorListItem) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
className="font-semibold uppercase"
|
||||
sx={{ color: "text.secondary", mb: 1 }}
|
||||
<Text
|
||||
variant="label"
|
||||
tone="muted"
|
||||
className="font-semibold tracking-[0.06em] uppercase"
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
const display = value && value.trim() !== "" ? value : "—";
|
||||
function Field({ label, value }: { label: string; value?: string }) {
|
||||
const display = !value || value.trim() === "" ? "—" : value;
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
|
||||
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2">{display}</Typography>
|
||||
</Text>
|
||||
<Text variant="description">{display}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TechnicianEntry({
|
||||
contactName,
|
||||
phone,
|
||||
email,
|
||||
preferredContact,
|
||||
tradeSpecialties,
|
||||
isActive,
|
||||
totalJobs,
|
||||
}: {
|
||||
contactName: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
preferredContact?: string;
|
||||
tradeSpecialties: string;
|
||||
isActive: boolean;
|
||||
totalJobs: number;
|
||||
}) {
|
||||
const trades = splitTrades(tradeSpecialties);
|
||||
return (
|
||||
<ListItem disableGutters divider>
|
||||
<Stack spacing={0.5} className="w-full">
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<Typography variant="body2" className="font-semibold">
|
||||
{contactName || "—"}
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={isActive ? "Active" : "Inactive"}
|
||||
color={isActive ? "success" : "default"}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{totalJobs} jobs
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={2} className="flex flex-wrap">
|
||||
{Boolean(phone) && (
|
||||
<Link href={`tel:${phone}`} underline="hover">
|
||||
{phone}
|
||||
</Link>
|
||||
)}
|
||||
{Boolean(email) && (
|
||||
<Link href={`mailto:${email}`} underline="hover">
|
||||
{email}
|
||||
</Link>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "wrap" }}>
|
||||
{Boolean(preferredContact) && (
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
Prefers {preferredContact}
|
||||
</Typography>
|
||||
)}
|
||||
{trades.map((trade, index) => (
|
||||
<Chip
|
||||
key={`${trade}-${index}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={index === 0 ? `${trade} (primary)` : trade}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({
|
||||
roster,
|
||||
vendor,
|
||||
technician,
|
||||
onClose,
|
||||
}: {
|
||||
roster?: VendorCompanyRoster;
|
||||
vendor: VendorListItem | null;
|
||||
technician?: VendorRosterTechnician;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
|
|
@ -141,23 +87,18 @@ function DrawerHeader({
|
|||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
px: 3,
|
||||
px: 2.5,
|
||||
py: 2,
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography
|
||||
id="vendor-detail-drawer-title"
|
||||
variant="subtitle1"
|
||||
component="h2"
|
||||
className="font-semibold"
|
||||
>
|
||||
{roster?.name || vendor?.companyName || "Vendor company"}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{roster ? `${roster.technicians.length} technician(s)` : "Vendor details"}
|
||||
</Typography>
|
||||
<Text id="vendor-detail-drawer-title" variant="title" as="h2" className="font-semibold">
|
||||
{technician?.contactName || vendor?.contactName || roster?.name || "Vendor company"}
|
||||
</Text>
|
||||
<Text variant="description" tone="muted">
|
||||
{roster?.name || vendor?.companyName || "Vendor details"}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton aria-label="Close drawer" onClick={onClose} size="small">
|
||||
<CloseIcon fontSize="small" />
|
||||
|
|
@ -167,18 +108,19 @@ function DrawerHeader({
|
|||
}
|
||||
|
||||
function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
|
||||
const mapsUrl = roster.googleMapsUrl;
|
||||
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="Email" value={roster.email} />
|
||||
<Field
|
||||
label="Address"
|
||||
value={[roster.address, roster.city, roster.state, roster.zip].filter(Boolean).join(", ")}
|
||||
/>
|
||||
{Boolean(mapsUrl) && (
|
||||
<Field label="Company Email" value={roster.email} />
|
||||
<Field label="Address" value={address} />
|
||||
{Boolean(roster.googleMapsUrl) && (
|
||||
<Link
|
||||
href={mapsUrl}
|
||||
href={roster.googleMapsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1"
|
||||
|
|
@ -191,49 +133,79 @@ function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
|
|||
);
|
||||
}
|
||||
|
||||
function TechniciansSection({ roster }: { roster: VendorCompanyRoster }) {
|
||||
function TechnicianSection({ technician }: { technician: VendorRosterTechnician }) {
|
||||
const trades = splitTrades(technician.tradeSpecialties);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SectionTitle>Technicians</SectionTitle>
|
||||
{roster.technicians.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
No technicians assigned to this company.
|
||||
</Typography>
|
||||
) : (
|
||||
<List disablePadding>
|
||||
{roster.technicians.map((technician) => (
|
||||
<TechnicianEntry
|
||||
key={String(technician.id ?? technician.contactName)}
|
||||
contactName={technician.contactName}
|
||||
phone={technician.phone}
|
||||
email={technician.email}
|
||||
preferredContact={technician.preferredContact}
|
||||
tradeSpecialties={technician.tradeSpecialties}
|
||||
isActive={technician.isActive}
|
||||
totalJobs={technician.totalJobs}
|
||||
<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" spacing={4} sx={{ alignItems: "flex-end" }}>
|
||||
<Box>
|
||||
<Text variant="title">{technician.totalJobs ?? 0}</Text>
|
||||
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
|
||||
Total Jobs
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text variant="caption" tone="muted" sx={{ display: "block", mb: 0.5 }}>
|
||||
Status
|
||||
</Text>
|
||||
<Chip
|
||||
size="small"
|
||||
label={technician.isActive ? "Active" : "Inactive"}
|
||||
color={technician.isActive ? "success" : "default"}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerBody({ roster }: { roster: VendorCompanyRoster }) {
|
||||
function DrawerBody({
|
||||
roster,
|
||||
technician,
|
||||
}: {
|
||||
roster: VendorCompanyRoster;
|
||||
technician?: VendorRosterTechnician;
|
||||
}) {
|
||||
return (
|
||||
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
|
||||
<SectionTitle>Company</SectionTitle>
|
||||
<CompanySection roster={roster} />
|
||||
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ p: 2.5 }}>
|
||||
<Box>
|
||||
<SectionTitle>Company</SectionTitle>
|
||||
<CompanySection roster={roster} />
|
||||
</Box>
|
||||
<Divider />
|
||||
<TechniciansSection roster={roster} />
|
||||
{technician ? (
|
||||
<TechnicianSection technician={technician} />
|
||||
) : (
|
||||
<Text variant="description" tone="muted">
|
||||
No technicians assigned to this company.
|
||||
</Text>
|
||||
)}
|
||||
{Boolean(roster.notes) && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box>
|
||||
<SectionTitle>Notes</SectionTitle>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
|
||||
<Text variant="description" sx={{ whiteSpace: "pre-wrap" }}>
|
||||
{roster.notes}
|
||||
</Typography>
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -246,8 +218,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) {
|
|||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 2,
|
||||
px: 2.5,
|
||||
py: 1.5,
|
||||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
|
|
@ -260,8 +232,143 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawerProps) {
|
||||
const navigate = useNavigate();
|
||||
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 selectedTotalJobs =
|
||||
roster?.technicians.find(
|
||||
(technician) => technician.id != null && String(technician.id) === String(vendor.id),
|
||||
)?.totalJobs ?? vendor.totalJobs;
|
||||
|
||||
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}
|
||||
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>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name={`technicians.${selectedIndex}.isActive`}
|
||||
render={({ field }) => (
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<Text variant="description">{field.value ? "Active" : "Inactive"}</Text>
|
||||
<Switch
|
||||
checked={Boolean(field.value)}
|
||||
slotProps={{ input: { "aria-label": "Active status" } }}
|
||||
onChange={(_event, checked) => {
|
||||
if (field.value && !checked) onRequestDeactivation(vendor);
|
||||
else field.onChange(checked);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
</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.isValid}>
|
||||
{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;
|
||||
|
|
@ -274,25 +381,30 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer
|
|||
!hasVendorId ? (companyId ?? undefined) : undefined,
|
||||
);
|
||||
|
||||
const editHref = hasVendorId
|
||||
? `/vendors/edit/${vendorId}`
|
||||
: `/vendors/company/${companyId ?? ""}`;
|
||||
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") onClose();
|
||||
if (event.key === "Escape" && !isEditing) onClose();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
return () => window.removeEventListener("keydown", closeOnEscape);
|
||||
}, [onClose, open]);
|
||||
}, [isEditing, onClose, open]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onClose={(_event, reason) => {
|
||||
if (reason === "escapeKeyDown" && isEditing) return;
|
||||
onClose();
|
||||
}}
|
||||
anchor="right"
|
||||
aria-labelledby="vendor-detail-drawer-title"
|
||||
ModalProps={{ keepMounted: false }}
|
||||
|
|
@ -304,9 +416,21 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer
|
|||
}}
|
||||
>
|
||||
<Stack className="flex h-full flex-col">
|
||||
<DrawerHeader roster={roster} vendor={vendor} onClose={onClose} />
|
||||
<DrawerHeader
|
||||
roster={roster}
|
||||
vendor={vendor}
|
||||
technician={selectedTechnician}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
{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>
|
||||
|
|
@ -316,14 +440,14 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer
|
|||
</Alert>
|
||||
) : roster ? (
|
||||
<>
|
||||
<DrawerBody roster={roster} />
|
||||
<DrawerActions onEdit={() => navigate(editHref)} />
|
||||
<DrawerBody roster={roster} technician={selectedTechnician} />
|
||||
<DrawerActions onEdit={() => setIsEditing(true)} />
|
||||
</>
|
||||
) : (
|
||||
<Box className="flex flex-1 items-center justify-center">
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
<Text variant="description" tone="muted">
|
||||
No vendor selected.
|
||||
</Typography>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
|
|
|||
252
src/app/(protected)/vendors/_components/vendor-filter-options.tsx
vendored
Normal file
252
src/app/(protected)/vendors/_components/vendor-filter-options.tsx
vendored
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
|
||||
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
|
||||
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Chip,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type {
|
||||
VendorFacets,
|
||||
VendorFacetCompany,
|
||||
VendorFacetJobBucket,
|
||||
VendorFacetLocation,
|
||||
} from "@/domain/vendors/types/vendor";
|
||||
import type { VendorFilters } from "./vendor-filters";
|
||||
|
||||
type DraftSetter = Dispatch<SetStateAction<VendorFilters>>;
|
||||
|
||||
function FilterSection({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Accordion disableGutters elevation={0} square sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${title}-filters-content`}>
|
||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
|
||||
<Typography variant="subtitle1" className="font-semibold">
|
||||
{title}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails id={`${title}-filters-content`} sx={{ pt: 0, pb: 2.5 }}>
|
||||
{children}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
|
||||
function CompanyFilter({
|
||||
options,
|
||||
draft,
|
||||
setDraft,
|
||||
}: {
|
||||
options: VendorFacetCompany[];
|
||||
draft: VendorFilters;
|
||||
setDraft: DraftSetter;
|
||||
}) {
|
||||
const allSelected = draft.companies.length === options.length && options.length > 0;
|
||||
return (
|
||||
<FilterSection title="Company" icon={<BusinessOutlinedIcon />}>
|
||||
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
Select vendor companies
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
companies: allSelected ? [] : options.map((company) => company.name),
|
||||
}))
|
||||
}
|
||||
>
|
||||
{allSelected ? "Clear all" : "Select all"}
|
||||
</Button>
|
||||
</Stack>
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={options}
|
||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
typeof option === "string" || typeof value === "string"
|
||||
? option === value
|
||||
: option.name === value.name
|
||||
}
|
||||
value={options.filter((option) => draft.companies.includes(option.name))}
|
||||
onChange={(_event, value) => {
|
||||
const next = (value as (string | VendorFacetCompany)[]).map((entry) =>
|
||||
typeof entry === "string" ? entry : entry.name,
|
||||
);
|
||||
setDraft((current) => ({ ...current, companies: next }));
|
||||
}}
|
||||
renderOption={(props, option, { selected }) => {
|
||||
const { key, ...optionProps } = props;
|
||||
return (
|
||||
<li key={key} {...optionProps}>
|
||||
<Checkbox checked={selected} size="small" sx={{ mr: 1 }} />
|
||||
{option.name}
|
||||
</li>
|
||||
);
|
||||
}}
|
||||
renderValue={(value, getItemProps) =>
|
||||
value.map((option, index) => {
|
||||
const label = typeof option === "string" ? option : option.name;
|
||||
const { key, ...chipProps } = getItemProps({ index });
|
||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={draft.companies.length === 0 ? "All companies" : ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FilterSection>
|
||||
);
|
||||
}
|
||||
|
||||
function TradeFilter({ options, draft, setDraft }: FilterProps<string>) {
|
||||
return (
|
||||
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={options}
|
||||
value={draft.trades}
|
||||
onChange={(_event, value) =>
|
||||
setDraft((current) => ({ ...current, trades: value as string[] }))
|
||||
}
|
||||
renderValue={(value, getItemProps) =>
|
||||
value.map((option, index) => {
|
||||
const { key, ...chipProps } = getItemProps({ index });
|
||||
return <Chip key={key} size="small" label={option} {...chipProps} />;
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} placeholder={draft.trades.length === 0 ? "All trades" : ""} />
|
||||
)}
|
||||
/>
|
||||
</FilterSection>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilterProps<T> {
|
||||
options: T[];
|
||||
draft: VendorFilters;
|
||||
setDraft: DraftSetter;
|
||||
}
|
||||
|
||||
function LocationFilter({ options, draft, setDraft }: FilterProps<VendorFacetLocation>) {
|
||||
return (
|
||||
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={options}
|
||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
typeof option === "string" || typeof value === "string"
|
||||
? option === value
|
||||
: option.label === value.label
|
||||
}
|
||||
value={options.filter((option) => draft.locations.includes(option.label))}
|
||||
onChange={(_event, value) => {
|
||||
const next = (value as (string | VendorFacetLocation)[]).map((entry) =>
|
||||
typeof entry === "string" ? entry : entry.label,
|
||||
);
|
||||
setDraft((current) => ({ ...current, locations: next }));
|
||||
}}
|
||||
renderValue={(value, getItemProps) =>
|
||||
value.map((option, index) => {
|
||||
const label = typeof option === "string" ? option : option.label;
|
||||
const { key, ...chipProps } = getItemProps({ index });
|
||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={draft.locations.length === 0 ? "All locations" : ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FilterSection>
|
||||
);
|
||||
}
|
||||
|
||||
function JobCountFilter({ options, draft, setDraft }: FilterProps<VendorFacetJobBucket>) {
|
||||
return (
|
||||
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={options}
|
||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
typeof option === "string" || typeof value === "string"
|
||||
? option === value
|
||||
: option.id === value.id
|
||||
}
|
||||
value={options.filter((option) => draft.jobBuckets.includes(option.id))}
|
||||
onChange={(_event, value) => {
|
||||
const next = (value as (string | VendorFacetJobBucket)[]).map((entry) =>
|
||||
typeof entry === "string" ? entry : entry.id,
|
||||
);
|
||||
setDraft((current) => ({ ...current, jobBuckets: next }));
|
||||
}}
|
||||
renderValue={(value, getItemProps) =>
|
||||
value.map((option, index) => {
|
||||
const label = typeof option === "string" ? option : option.label;
|
||||
const { key, ...chipProps } = getItemProps({ index });
|
||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FilterSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function VendorFilterOptions({
|
||||
facets,
|
||||
draft,
|
||||
setDraft,
|
||||
}: {
|
||||
facets: VendorFacets | undefined;
|
||||
draft: VendorFilters;
|
||||
setDraft: DraftSetter;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<CompanyFilter options={facets?.companies ?? []} draft={draft} setDraft={setDraft} />
|
||||
<TradeFilter options={facets?.trades ?? []} draft={draft} setDraft={setDraft} />
|
||||
<LocationFilter options={facets?.locations ?? []} draft={draft} setDraft={setDraft} />
|
||||
<JobCountFilter options={facets?.jobBuckets ?? []} draft={draft} setDraft={setDraft} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material";
|
||||
import type { VendorFacets } from "@/domain/vendors/types/vendor";
|
||||
import { VendorFilterOptions } from "./vendor-filter-options";
|
||||
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
|
||||
import { VendorFiltersDrawerFields } from "./vendor-filters-drawer-fields";
|
||||
|
||||
interface VendorFiltersDrawerProps {
|
||||
open: boolean;
|
||||
|
|
@ -30,11 +30,6 @@ export function VendorFiltersDrawer({
|
|||
}
|
||||
}, [open, appliedFilters]);
|
||||
|
||||
const companyOptions = useMemo(() => facets?.companies ?? [], [facets]);
|
||||
const tradeOptions = useMemo(() => facets?.trades ?? [], [facets]);
|
||||
const locationOptions = useMemo(() => facets?.locations ?? [], [facets]);
|
||||
const jobBucketOptions = useMemo(() => facets?.jobBuckets ?? [], [facets]);
|
||||
|
||||
const activeCount =
|
||||
draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length;
|
||||
|
||||
|
|
@ -84,16 +79,7 @@ export function VendorFiltersDrawer({
|
|||
</Stack>
|
||||
|
||||
<Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}>
|
||||
<VendorFiltersDrawerFields
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
options={{
|
||||
companies: companyOptions,
|
||||
trades: tradeOptions,
|
||||
locations: locationOptions,
|
||||
jobBuckets: jobBucketOptions,
|
||||
}}
|
||||
/>
|
||||
<VendorFilterOptions facets={facets} draft={draft} setDraft={setDraft} />
|
||||
</Box>
|
||||
|
||||
<Stack
|
||||
|
|
@ -109,7 +95,7 @@ export function VendorFiltersDrawer({
|
|||
Clear all
|
||||
</Button>
|
||||
<Button variant="contained" onClick={handleApply} fullWidth>
|
||||
Apply
|
||||
Apply filters
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,4 @@
|
|||
import {
|
||||
Controller,
|
||||
useFieldArray,
|
||||
useWatch,
|
||||
type Control,
|
||||
type FieldErrors,
|
||||
} from "react-hook-form";
|
||||
import { Controller, useFieldArray, type Control, type FieldErrors } from "react-hook-form";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import {
|
||||
|
|
@ -34,7 +28,7 @@ interface VendorRosterFormFieldsProps {
|
|||
selectedCompanyId?: string | number | null;
|
||||
onSelectCompany?: (company: VendorFacetCompany | null) => Promise<void>;
|
||||
onClearSelectedCompany?: (nextName?: string) => void;
|
||||
onRequestDeactivate?: (technician: RosterTechnicianValues) => void;
|
||||
showTechnicianStatus?: boolean;
|
||||
}
|
||||
|
||||
function CompanyNameField({
|
||||
|
|
@ -61,6 +55,7 @@ function CompanyNameField({
|
|||
<TextField
|
||||
{...field}
|
||||
label="Company"
|
||||
placeholder="Start typing a company…"
|
||||
required
|
||||
error={Boolean(errors.name)}
|
||||
helperText={errors.name?.message}
|
||||
|
|
@ -105,8 +100,9 @@ function CompanyNameField({
|
|||
{...params}
|
||||
label="Company"
|
||||
required
|
||||
placeholder="Start typing a company…"
|
||||
error={Boolean(errors.name)}
|
||||
helperText={errors.name?.message ?? "Choose an existing company or enter a new one"}
|
||||
helperText={errors.name?.message}
|
||||
inputRef={field.ref}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -126,10 +122,7 @@ function CompanyFields({
|
|||
}: VendorRosterFormFieldsProps) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||
Company
|
||||
</Typography>
|
||||
<Stack spacing={2} className="mt-2">
|
||||
<Stack spacing={2}>
|
||||
<CompanyNameField
|
||||
control={control}
|
||||
errors={errors}
|
||||
|
|
@ -145,13 +138,13 @@ function CompanyFields({
|
|||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Company Phone"
|
||||
placeholder="(XXX) XXX-XXXX"
|
||||
label="Company Phone (optional)"
|
||||
placeholder="(214) 555-0100"
|
||||
inputMode="tel"
|
||||
value={field.value ?? ""}
|
||||
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
|
||||
error={Boolean(errors.companyPhone)}
|
||||
helperText={errors.companyPhone?.message ?? "Phone or email required"}
|
||||
helperText={errors.companyPhone?.message}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
|
|
@ -162,7 +155,8 @@ function CompanyFields({
|
|||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Email"
|
||||
label="Email (optional)"
|
||||
placeholder="dispatch@vendor.com"
|
||||
type="email"
|
||||
error={Boolean(errors.email)}
|
||||
helperText={errors.email?.message}
|
||||
|
|
@ -174,9 +168,16 @@ function CompanyFields({
|
|||
<Controller
|
||||
control={control}
|
||||
name="address"
|
||||
render={({ field }) => <TextField {...field} label="Address" fullWidth />}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Address (optional)"
|
||||
placeholder="123 Industrial Pkwy, Dallas, TX 75201"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ display: "none" }}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="city"
|
||||
|
|
@ -201,10 +202,10 @@ function CompanyFields({
|
|||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Google Maps URL"
|
||||
placeholder="https://maps.google.com/..."
|
||||
label="Google Maps URL (optional)"
|
||||
placeholder="https://maps.google.com/…"
|
||||
error={Boolean(errors.googleMapsUrl)}
|
||||
helperText={errors.googleMapsUrl?.message ?? "Must be an HTTPS link"}
|
||||
helperText={errors.googleMapsUrl?.message}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
|
|
@ -213,7 +214,14 @@ function CompanyFields({
|
|||
control={control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<TextField {...field} label="Notes" multiline minRows={2} fullWidth />
|
||||
<TextField
|
||||
{...field}
|
||||
label="Notes (optional)"
|
||||
placeholder="Internal notes, scheduling preferences, certifications…"
|
||||
multiline
|
||||
minRows={2}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
|
|
@ -228,7 +236,7 @@ interface TechnicianRowProps {
|
|||
onRemove: () => void;
|
||||
canRemove: boolean;
|
||||
tradeOptions: string[];
|
||||
onRequestDeactivate?: (technician: RosterTechnicianValues) => void;
|
||||
showStatus: boolean;
|
||||
}
|
||||
|
||||
function TechnicianRow({
|
||||
|
|
@ -238,15 +246,13 @@ function TechnicianRow({
|
|||
onRemove,
|
||||
canRemove,
|
||||
tradeOptions,
|
||||
onRequestDeactivate,
|
||||
showStatus,
|
||||
}: TechnicianRowProps) {
|
||||
const technician = useWatch({ control, name: `technicians.${index}` });
|
||||
|
||||
return (
|
||||
<Stack spacing={2} className="rounded-md border border-solid border-gray-200 p-3">
|
||||
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
||||
<Typography variant="subtitle2" className="font-semibold">
|
||||
Technician {index + 1}
|
||||
{index + 1} - TECHNICIAN
|
||||
</Typography>
|
||||
<Tooltip title="Remove technician">
|
||||
<span>
|
||||
|
|
@ -267,7 +273,8 @@ function TechnicianRow({
|
|||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Technician Name"
|
||||
label="Technician name (optional)"
|
||||
placeholder="Technician name"
|
||||
error={Boolean(error?.contactName)}
|
||||
helperText={error?.contactName?.message}
|
||||
fullWidth
|
||||
|
|
@ -281,8 +288,8 @@ function TechnicianRow({
|
|||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Technician Phone"
|
||||
placeholder="(XXX) XXX-XXXX"
|
||||
label="Technician phone (optional)"
|
||||
placeholder="(000) 000-0000"
|
||||
inputMode="tel"
|
||||
value={field.value ?? ""}
|
||||
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
|
||||
|
|
@ -303,32 +310,29 @@ function TechnicianRow({
|
|||
error={Boolean(error?.email)}
|
||||
helperText={error?.email?.message}
|
||||
fullWidth
|
||||
sx={{ display: "none" }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
<VendorTradeSpecialtiesField control={control} index={index} tradeOptions={tradeOptions} />
|
||||
<Controller
|
||||
control={control}
|
||||
name={`technicians.${index}.isActive`}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onChange={(_event, checked) => {
|
||||
if (!checked && technician?.id != null && onRequestDeactivate != null) {
|
||||
onRequestDeactivate(technician);
|
||||
return;
|
||||
}
|
||||
field.onChange(checked);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={field.value ? "Active" : "Inactive"}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{showStatus && (
|
||||
<Controller
|
||||
control={control}
|
||||
name={`technicians.${index}.isActive`}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onChange={(_event, checked) => field.onChange(checked)}
|
||||
/>
|
||||
}
|
||||
label={field.value ? "Active" : "Inactive"}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -337,12 +341,12 @@ function TechniciansFieldArray({
|
|||
control,
|
||||
errors,
|
||||
tradeOptions,
|
||||
onRequestDeactivate,
|
||||
showStatus,
|
||||
}: {
|
||||
control: Control<VendorCompanyRosterFormValues>;
|
||||
errors: FieldErrors<VendorCompanyRosterFormValues>;
|
||||
tradeOptions: string[];
|
||||
onRequestDeactivate?: (technician: RosterTechnicianValues) => void;
|
||||
showStatus: boolean;
|
||||
}) {
|
||||
const { fields, append, remove } = useFieldArray({ control, name: "technicians" });
|
||||
|
||||
|
|
@ -385,7 +389,7 @@ function TechniciansFieldArray({
|
|||
onRemove={() => remove(index)}
|
||||
canRemove
|
||||
tradeOptions={tradeOptions}
|
||||
onRequestDeactivate={onRequestDeactivate}
|
||||
showStatus={showStatus}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
|
@ -395,7 +399,7 @@ function TechniciansFieldArray({
|
|||
}
|
||||
|
||||
export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
|
||||
const { control, errors, tradeOptions = [], onRequestDeactivate } = props;
|
||||
const { control, errors, tradeOptions = [], showTechnicianStatus = true } = props;
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<CompanyFields {...props} />
|
||||
|
|
@ -404,7 +408,7 @@ export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
|
|||
control={control}
|
||||
errors={errors}
|
||||
tradeOptions={tradeOptions}
|
||||
onRequestDeactivate={onRequestDeactivate}
|
||||
showStatus={showTechnicianStatus}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,44 +1,17 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material";
|
||||
import { VendorDeactivationDialog } from "./vendor-deactivation-dialog";
|
||||
import { VendorPortalTokenPanel } from "./vendor-portal-token-panel";
|
||||
import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
|
||||
import { VendorRosterFormFields } from "./vendor-roster-form-fields";
|
||||
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
|
||||
import { useVendorDeactivation } from "./use-vendor-deactivation";
|
||||
import { useVendorRosterForm } from "./use-vendor-roster-form";
|
||||
import type { RosterTechnicianValues } from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||
import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor";
|
||||
|
||||
interface VendorRosterPageProps {
|
||||
vendorId?: string;
|
||||
companyId?: string;
|
||||
}
|
||||
|
||||
function toDeactivationListItem(
|
||||
roster: VendorCompanyRoster,
|
||||
technician: RosterTechnicianValues,
|
||||
): VendorListItem {
|
||||
return {
|
||||
id: technician.id ?? null,
|
||||
companyId: roster.companyId,
|
||||
companyName: roster.name,
|
||||
contactName: technician.contactName,
|
||||
email: technician.email,
|
||||
phone: technician.phone,
|
||||
companyPhone: roster.companyPhone,
|
||||
googleMapsUrl: roster.googleMapsUrl,
|
||||
notes: roster.notes,
|
||||
totalJobs: 0,
|
||||
city: roster.city,
|
||||
state: roster.state,
|
||||
tradeSpecialties: technician.tradeSpecialties,
|
||||
isActive: technician.isActive,
|
||||
preferredContact: technician.preferredContact ?? "Phone",
|
||||
};
|
||||
}
|
||||
|
||||
function PageShell({
|
||||
title,
|
||||
subtitle,
|
||||
|
|
@ -101,9 +74,6 @@ function submitLabel(isSaving: boolean, isEdit: boolean): string {
|
|||
export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const isEdit = vendorId !== undefined || companyId !== undefined;
|
||||
const deactivation = useVendorDeactivation({
|
||||
onSuccess: () => navigate("/vendors"),
|
||||
});
|
||||
const form = useVendorRosterForm({
|
||||
mode: isEdit ? "update" : "create",
|
||||
vendorId,
|
||||
|
|
@ -111,11 +81,6 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
|
|||
onSuccess: () => navigate("/vendors"),
|
||||
});
|
||||
|
||||
const handleRequestDeactivate = (technician: RosterTechnicianValues) => {
|
||||
if (!form.roster || technician.id == null) return;
|
||||
deactivation.open(toDeactivationListItem(form.roster, technician));
|
||||
};
|
||||
|
||||
if (isEdit && form.isLoading) {
|
||||
return (
|
||||
<Box className="flex justify-center p-10">
|
||||
|
|
@ -159,7 +124,6 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
|
|||
control={form.control}
|
||||
errors={form.errors}
|
||||
tradeOptions={form.trades}
|
||||
onRequestDeactivate={isEdit ? handleRequestDeactivate : undefined}
|
||||
{...companySelectionProps}
|
||||
/>
|
||||
|
||||
|
|
@ -181,17 +145,6 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
|
|||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<VendorDeactivationDialog
|
||||
target={deactivation.target}
|
||||
isLoading={deactivation.isLoading}
|
||||
isSaving={deactivation.isSaving}
|
||||
error={deactivation.error}
|
||||
impact={deactivation.impact}
|
||||
impactError={deactivation.impactError}
|
||||
onClose={deactivation.close}
|
||||
onConfirm={deactivation.confirm}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Stack,
|
||||
TablePagination,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { Button, Chip, CircularProgress, Stack, TablePagination } from "@mui/material";
|
||||
import { Plus, SlidersHorizontal } from "lucide-react";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { SearchInput } from "@/components/ui/search-input";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { ToolbarButton } from "@/components/ui/toolbar-button";
|
||||
|
||||
interface VendorsListToolbarProps {
|
||||
globalCount: number;
|
||||
|
|
@ -33,55 +29,46 @@ export function VendorsListToolbar({
|
|||
onAddVendor,
|
||||
}: VendorsListToolbarProps) {
|
||||
return (
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: { xs: "stretch", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
|
||||
<Typography variant="h5" component="h1">
|
||||
Vendors / Technicians
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{globalCount} vendor entries · {activeCount} active
|
||||
</Typography>
|
||||
{isFetching && !isLoading && (
|
||||
<CircularProgress size={16} aria-label="Refreshing vendors" role="status" />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={2}
|
||||
sx={{ alignItems: { xs: "stretch", sm: "center" } }}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search name, company, trade..."
|
||||
className="min-w-[260px]"
|
||||
aria-label="Search vendors"
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={onOpenFilters}
|
||||
startIcon={<FilterListIcon />}
|
||||
aria-label="Open vendor filters"
|
||||
>
|
||||
Filters
|
||||
{appliedFilterCount > 0 && (
|
||||
<Chip size="small" color="primary" label={appliedFilterCount} sx={{ ml: 1 }} />
|
||||
<PageHeader
|
||||
surface
|
||||
className="-mx-4 -mt-4"
|
||||
title="Vendors / Technicians"
|
||||
subtitle={`${globalCount} technicians · ${activeCount} active`}
|
||||
actions={
|
||||
<>
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={onSearchChange}
|
||||
placeholder="Search name, company, trade…"
|
||||
aria-label="Search vendors"
|
||||
fullWidth
|
||||
className="w-full md:w-56"
|
||||
inputClassName="h-8 py-0 text-[13px]"
|
||||
/>
|
||||
<ToolbarButton
|
||||
variant="outline"
|
||||
icon={<SlidersHorizontal size={16} />}
|
||||
onClick={onOpenFilters}
|
||||
aria-label="Open vendor filters"
|
||||
badge={appliedFilterCount}
|
||||
className="min-w-[88.609375px] flex-1 text-[13px] md:w-[88.609375px] md:flex-none"
|
||||
>
|
||||
Filters
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
variant="primary"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={onAddVendor}
|
||||
className="min-w-[115.546875px] flex-1 border-0 px-3 text-[13px] md:w-[115.546875px] md:flex-none"
|
||||
>
|
||||
Add Vendor
|
||||
</ToolbarButton>
|
||||
{isFetching && !isLoading && (
|
||||
<CircularProgress size={14} aria-label="Refreshing vendors" role="status" />
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="contained" onClick={onAddVendor}>
|
||||
+ Add Vendor
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -93,16 +80,21 @@ interface AppliedChipEntry {
|
|||
|
||||
interface VendorAppliedFiltersBarProps {
|
||||
chips: AppliedChipEntry[];
|
||||
resultCount: number;
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
export function VendorAppliedFiltersBar({ chips, onClearAll }: VendorAppliedFiltersBarProps) {
|
||||
export function VendorAppliedFiltersBar({
|
||||
chips,
|
||||
resultCount,
|
||||
onClearAll,
|
||||
}: VendorAppliedFiltersBarProps) {
|
||||
if (chips.length === 0) return null;
|
||||
return (
|
||||
<Stack direction="row" spacing={1} className="flex flex-wrap items-center">
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
Applied:
|
||||
</Typography>
|
||||
<Text variant="caption" tone="muted">
|
||||
{resultCount} {resultCount === 1 ? "technician" : "technicians"} · filtered by
|
||||
</Text>
|
||||
{chips.map((chip) => (
|
||||
<Chip
|
||||
key={chip.key}
|
||||
|
|
@ -149,23 +141,31 @@ export function VendorsListFooter({
|
|||
sx={{
|
||||
alignItems: { xs: "stretch", sm: "center" },
|
||||
justifyContent: "space-between",
|
||||
minHeight: 33.5,
|
||||
mx: -2,
|
||||
px: 2,
|
||||
backgroundColor: "var(--color-bg-surface)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
<Text variant="caption" as="span" sx={{ color: "#6b7a96", fontSize: 11, lineHeight: 1.5 }}>
|
||||
{totalCount} {isActive ? "active" : "inactive"}{" "}
|
||||
{totalCount === 1 ? "vendor entry" : "vendor entries"}
|
||||
{totalCount === 1 ? "technician" : "technicians"}
|
||||
{isFiltered && " · filtered"}
|
||||
{` · ${activeCount} active · ${inactiveCount} inactive`}
|
||||
</Typography>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={(_e, nextPage) => onPageChange(nextPage)}
|
||||
rowsPerPage={pageSize}
|
||||
onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
/>
|
||||
</Text>
|
||||
<Text variant="caption" as="span" sx={{ color: "#6b7a96", fontSize: 11, lineHeight: 1.5 }}>
|
||||
{activeCount} active · {inactiveCount} inactive
|
||||
</Text>
|
||||
{totalCount > pageSize && (
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={(_e, nextPage) => onPageChange(nextPage)}
|
||||
rowsPerPage={pageSize}
|
||||
onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
rowsPerPageOptions={[24, 48, 96]}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { type KeyboardEvent, type MouseEvent } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||
import {
|
||||
Avatar,
|
||||
Chip,
|
||||
Box,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Link,
|
||||
|
|
@ -18,8 +17,8 @@ import {
|
|||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
||||
|
||||
const TABLE_COLUMNS = [
|
||||
|
|
@ -30,9 +29,6 @@ const TABLE_COLUMNS = [
|
|||
"Phone",
|
||||
"Email",
|
||||
"Total Jobs",
|
||||
"Status",
|
||||
"View",
|
||||
"Edit",
|
||||
] as const;
|
||||
|
||||
function getInitials(name: string): string {
|
||||
|
|
@ -54,19 +50,34 @@ function stopPropagation(event: MouseEvent): void {
|
|||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function VendorStatus({ isActive }: { isActive: boolean }) {
|
||||
return (
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center", pl: 1 }}>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "9999px",
|
||||
backgroundColor: isActive ? "success.main" : "text.secondary",
|
||||
}}
|
||||
/>
|
||||
<Text variant="description">{isActive ? "Active" : "Inactive"}</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface VendorTableRowProps {
|
||||
row: VendorListItem;
|
||||
onOpenDetail: (row: VendorListItem) => void;
|
||||
onOpenDeactivation: (row: VendorListItem) => void;
|
||||
onOpenEdit: (row: VendorListItem) => void;
|
||||
}
|
||||
|
||||
function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRowProps) {
|
||||
const navigate = useNavigate();
|
||||
function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) {
|
||||
const trades = splitTrades(row.tradeSpecialties);
|
||||
const primaryTrade = trades[0] ?? "—";
|
||||
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
|
||||
const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—";
|
||||
const hasVendorId = row.id != null;
|
||||
const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
|
|
@ -88,37 +99,62 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo
|
|||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<TableCell>
|
||||
<Typography className="font-semibold">{row.companyName || "—"}</Typography>
|
||||
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
|
||||
{row.companyName || "—"}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
fontSize: 12,
|
||||
backgroundColor: "var(--accent, #1976d2)",
|
||||
backgroundColor: "var(--color-bg-muted)",
|
||||
color: "var(--color-primary)",
|
||||
fontSize: 10.5,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{getInitials(row.contactName)}
|
||||
</Avatar>
|
||||
<Typography variant="body2">{row.contactName || "—"}</Typography>
|
||||
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
|
||||
{row.contactName || "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>{locationText}</TableCell>
|
||||
<TableCell>
|
||||
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>{locationText}</TableCell>
|
||||
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
|
||||
{primaryTrade === "—" ? (
|
||||
"—"
|
||||
) : (
|
||||
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
|
||||
<span>{primaryTrade}</span>
|
||||
{extraTrades > 0 && <Chip size="small" label={`+${extraTrades}`} />}
|
||||
{extraTrades > 0 && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
borderRadius: "9999px",
|
||||
backgroundColor: "var(--color-bg-muted)",
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
fontSize: 10.5,
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
+{extraTrades}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
|
||||
{row.phone ? (
|
||||
<Link href={`tel:${row.phone}`} underline="hover" onClick={stopPropagation}>
|
||||
<Link
|
||||
href={`tel:${row.phone}`}
|
||||
underline="hover"
|
||||
color="inherit"
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{row.phone}
|
||||
</Link>
|
||||
) : (
|
||||
|
|
@ -127,71 +163,53 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
{row.email ? (
|
||||
<Link href={`mailto:${row.email}`} underline="hover" onClick={stopPropagation}>
|
||||
<Link
|
||||
href={`mailto:${row.email}`}
|
||||
underline="hover"
|
||||
sx={{ color: "#6b7a96" }}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{row.email}
|
||||
</Link>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{row.totalJobs ?? 0}</TableCell>
|
||||
<TableCell align="center">{row.totalJobs ?? 0}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
size="small"
|
||||
label={row.isActive ? "Active" : "Inactive"}
|
||||
color={row.isActive ? "success" : "default"}
|
||||
/>
|
||||
<VendorStatus isActive={row.isActive} />
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Tooltip title="View details">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`View vendor ${row.companyName}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenDetail(row);
|
||||
}}
|
||||
>
|
||||
<VisibilityOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<TableCell align="right">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={0.5}
|
||||
sx={{ alignItems: "center", justifyContent: "center" }}
|
||||
className="vendor-row-actions"
|
||||
sx={{ alignItems: "center", justifyContent: "flex-end" }}
|
||||
>
|
||||
<Tooltip title="View details">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`View vendor ${row.companyName}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenDetail(row);
|
||||
}}
|
||||
>
|
||||
<VisibilityOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Edit">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`Edit vendor ${row.companyName}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
navigate(
|
||||
row.id != null
|
||||
? `/vendors/edit/${row.id}`
|
||||
: `/vendors/company/${row.companyId ?? ""}`,
|
||||
);
|
||||
onOpenEdit(row);
|
||||
}}
|
||||
>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{hasVendorId && row.isActive && (
|
||||
<Tooltip title="Deactivate">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Deactivate vendor"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenDeactivation(row);
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -200,50 +218,118 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo
|
|||
|
||||
interface VendorsTableProps {
|
||||
rows: VendorListItem[];
|
||||
isActive: boolean;
|
||||
isFiltered: boolean;
|
||||
isLoading: boolean;
|
||||
showError: boolean;
|
||||
onOpenDetail: (row: VendorListItem) => void;
|
||||
onOpenDeactivation: (row: VendorListItem) => void;
|
||||
onOpenEdit: (row: VendorListItem) => void;
|
||||
}
|
||||
|
||||
export function VendorsTable({
|
||||
rows,
|
||||
isActive,
|
||||
isFiltered,
|
||||
isLoading,
|
||||
showError,
|
||||
onOpenDetail,
|
||||
onOpenDeactivation,
|
||||
onOpenEdit,
|
||||
}: VendorsTableProps) {
|
||||
const showEmptyRow = !isLoading && !showError && rows.length === 0;
|
||||
const columnCount = TABLE_COLUMNS.length + 2;
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ overflowX: "auto" }}>
|
||||
<Table size="small" sx={{ minWidth: 960 }}>
|
||||
<TableContainer
|
||||
component={Paper}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
mt: 2,
|
||||
mb: 2,
|
||||
flex: "1 1 auto",
|
||||
overflow: "auto",
|
||||
borderRadius: 1.5,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
size="small"
|
||||
stickyHeader
|
||||
sx={{
|
||||
minWidth: 1550,
|
||||
tableLayout: "fixed",
|
||||
"& .MuiTableCell-head": {
|
||||
backgroundColor: "var(--color-bg-muted)",
|
||||
color: "var(--color-foreground)",
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
lineHeight: "24px",
|
||||
px: 1.75,
|
||||
py: 1.375,
|
||||
textTransform: "uppercase",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
"& .MuiTableCell-body": {
|
||||
fontSize: 13,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
height: 52,
|
||||
lineHeight: 1.43,
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
"& .vendor-row-actions": {
|
||||
opacity: { xs: 1, md: 0 },
|
||||
transition: "opacity 120ms ease",
|
||||
},
|
||||
"& .MuiTableRow-root:hover .vendor-row-actions, & .MuiTableRow-root:focus-within .vendor-row-actions":
|
||||
{
|
||||
opacity: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<colgroup>
|
||||
<col style={{ width: 196.34375 }} />
|
||||
<col style={{ width: 207.25 }} />
|
||||
<col style={{ width: 152.703125 }} />
|
||||
<col style={{ width: 218.15625 }} />
|
||||
<col style={{ width: 152.703125 }} />
|
||||
<col style={{ width: 251.90625 }} />
|
||||
<col style={{ width: 109.078125 }} />
|
||||
<col style={{ width: 119.984375 }} />
|
||||
<col style={{ width: 141.875 }} />
|
||||
</colgroup>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{TABLE_COLUMNS.map((column) => (
|
||||
<TableCell
|
||||
key={column}
|
||||
align={column === "View" || column === "Edit" ? "center" : "left"}
|
||||
className="font-semibold"
|
||||
>
|
||||
<TableCell key={column} align={column === "Total Jobs" ? "center" : "left"}>
|
||||
{column}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right" aria-label="Vendor actions" />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
|
||||
<TableCell colSpan={columnCount} align="center" className="py-10">
|
||||
<CircularProgress size={28} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : showEmptyRow ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
No vendors match the current filters.
|
||||
</Typography>
|
||||
<TableCell colSpan={columnCount} align="center" className="py-10">
|
||||
<Stack spacing={1} sx={{ alignItems: "center" }}>
|
||||
<SearchOffOutlinedIcon sx={{ color: "text.secondary", fontSize: 32 }} />
|
||||
<Text variant="description" className="font-semibold">
|
||||
{`No ${isActive ? "active" : "inactive"} technicians found`}
|
||||
</Text>
|
||||
<Text variant="caption" tone="muted">
|
||||
{isFiltered
|
||||
? "Try a different search, or add a new vendor."
|
||||
: "Add a vendor to get started."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
|
|
@ -252,7 +338,7 @@ export function VendorsTable({
|
|||
key={String(row.id ?? `company-${row.companyId ?? ""}`)}
|
||||
row={row}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onOpenDeactivation={onOpenDeactivation}
|
||||
onOpenEdit={onOpenEdit}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
125
src/app/(protected)/vendors/index.tsx
vendored
125
src/app/(protected)/vendors/index.tsx
vendored
|
|
@ -23,16 +23,91 @@ interface AppliedChip {
|
|||
label: string;
|
||||
}
|
||||
|
||||
function statusTabLabel(label: string, count: number) {
|
||||
return (
|
||||
<Box className="flex items-center gap-2">
|
||||
<Box component="span">{label}</Box>
|
||||
<Box
|
||||
component="span"
|
||||
className="min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none"
|
||||
sx={{ backgroundColor: "action.hover", color: "text.secondary" }}
|
||||
>
|
||||
{count}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function VendorStatusTabs({
|
||||
isActive,
|
||||
activeCount,
|
||||
inactiveCount,
|
||||
onChange,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
activeCount: number;
|
||||
inactiveCount: number;
|
||||
onChange: (isActive: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Tabs
|
||||
value={isActive ? "active" : "inactive"}
|
||||
onChange={(_event, value: "active" | "inactive") => onChange(value === "active")}
|
||||
aria-label="Vendor status"
|
||||
sx={{
|
||||
minHeight: 36,
|
||||
flexShrink: 0,
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
backgroundColor: "var(--color-bg-surface)",
|
||||
mx: -2,
|
||||
px: 1.5,
|
||||
"& .MuiTab-root": {
|
||||
minHeight: 36,
|
||||
px: 2,
|
||||
py: 0,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
textTransform: "none",
|
||||
},
|
||||
"& .MuiTab-root:first-of-type": { width: 102.84375 },
|
||||
"& .Mui-selected": { fontWeight: 600 },
|
||||
"& .MuiTabs-indicator": { height: 2 },
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
value="active"
|
||||
label={statusTabLabel("Active", activeCount)}
|
||||
aria-label={`Active (${activeCount})`}
|
||||
/>
|
||||
<Tab
|
||||
value="inactive"
|
||||
label={statusTabLabel("Inactive", inactiveCount)}
|
||||
aria-label={`Inactive (${inactiveCount})`}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function describeChips(
|
||||
filters: VendorFilters,
|
||||
jobBucketLabels: Map<string, string>,
|
||||
): AppliedChip[] {
|
||||
const chips: AppliedChip[] = [];
|
||||
filters.companies.forEach((value) => chips.push({ category: "companies", value, label: value }));
|
||||
filters.trades.forEach((value) => chips.push({ category: "trades", value, label: value }));
|
||||
filters.locations.forEach((value) => chips.push({ category: "locations", value, label: value }));
|
||||
filters.companies.forEach((value) =>
|
||||
chips.push({ category: "companies", value, label: `Company: ${value}` }),
|
||||
);
|
||||
filters.trades.forEach((value) =>
|
||||
chips.push({ category: "trades", value, label: `Trade: ${value}` }),
|
||||
);
|
||||
filters.locations.forEach((value) =>
|
||||
chips.push({ category: "locations", value, label: `Location: ${value}` }),
|
||||
);
|
||||
filters.jobBuckets.forEach((value) =>
|
||||
chips.push({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }),
|
||||
chips.push({
|
||||
category: "jobBuckets",
|
||||
value,
|
||||
label: `Total Jobs: ${jobBucketLabels.get(value) ?? value}`,
|
||||
}),
|
||||
);
|
||||
return chips;
|
||||
}
|
||||
|
|
@ -42,7 +117,12 @@ export default function VendorsListPage() {
|
|||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [detailTarget, setDetailTarget] = useState<VendorListItem | null>(null);
|
||||
const deactivation = useVendorDeactivation();
|
||||
const [detailStartsInEdit, setDetailStartsInEdit] = useState(false);
|
||||
const closeDetail = () => {
|
||||
setDetailTarget(null);
|
||||
setDetailStartsInEdit(false);
|
||||
};
|
||||
const deactivation = useVendorDeactivation(closeDetail);
|
||||
|
||||
const { data: facets } = useVendorFacets(filters.isActive);
|
||||
const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams);
|
||||
|
|
@ -61,7 +141,7 @@ export default function VendorsListPage() {
|
|||
const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels);
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Box className="flex h-full flex-col overflow-hidden px-4 pt-4 pb-0">
|
||||
<VendorsListToolbar
|
||||
globalCount={activeCount + inactiveCount}
|
||||
activeCount={activeCount}
|
||||
|
|
@ -74,18 +154,15 @@ export default function VendorsListPage() {
|
|||
onAddVendor={() => setCreateOpen(true)}
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
value={filters.isActive ? "active" : "inactive"}
|
||||
onChange={(_event, value: "active" | "inactive") =>
|
||||
filters.setIsActiveWithReset(value === "active")
|
||||
}
|
||||
aria-label="Vendor status"
|
||||
>
|
||||
<Tab value="active" label={`Active (${activeCount})`} />
|
||||
<Tab value="inactive" label={`Inactive (${inactiveCount})`} />
|
||||
</Tabs>
|
||||
<VendorStatusTabs
|
||||
isActive={filters.isActive}
|
||||
activeCount={activeCount}
|
||||
inactiveCount={inactiveCount}
|
||||
onChange={filters.setIsActiveWithReset}
|
||||
/>
|
||||
|
||||
<VendorAppliedFiltersBar
|
||||
resultCount={totalCount}
|
||||
chips={appliedChips.map((chip) => ({
|
||||
key: `${chip.category}-${chip.value}`,
|
||||
label: chip.label,
|
||||
|
|
@ -102,10 +179,18 @@ export default function VendorsListPage() {
|
|||
|
||||
<VendorsTable
|
||||
rows={rows}
|
||||
isActive={filters.isActive}
|
||||
isFiltered={appliedChips.length > 0 || Boolean(filters.debouncedSearch)}
|
||||
isLoading={isLoading}
|
||||
showError={Boolean(error)}
|
||||
onOpenDetail={setDetailTarget}
|
||||
onOpenDeactivation={deactivation.open}
|
||||
onOpenDetail={(row) => {
|
||||
setDetailStartsInEdit(false);
|
||||
setDetailTarget(row);
|
||||
}}
|
||||
onOpenEdit={(row) => {
|
||||
setDetailStartsInEdit(true);
|
||||
setDetailTarget(row);
|
||||
}}
|
||||
/>
|
||||
|
||||
<VendorsListFooter
|
||||
|
|
@ -133,7 +218,9 @@ export default function VendorsListPage() {
|
|||
<VendorDetailDrawer
|
||||
open={Boolean(detailTarget)}
|
||||
vendor={detailTarget}
|
||||
onClose={() => setDetailTarget(null)}
|
||||
startInEdit={detailStartsInEdit}
|
||||
onRequestDeactivation={deactivation.open}
|
||||
onClose={closeDetail}
|
||||
/>
|
||||
|
||||
<VendorDeactivationDialog
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
type DuplicateWoNumberDialogProps = {
|
||||
open: boolean;
|
||||
existing: WorkOrderTableRow | null;
|
||||
onDismiss: () => void;
|
||||
onViewExisting: (row: WorkOrderTableRow) => void;
|
||||
};
|
||||
|
||||
export function DuplicateWoNumberDialog({
|
||||
open,
|
||||
existing,
|
||||
onDismiss,
|
||||
onViewExisting,
|
||||
}: DuplicateWoNumberDialogProps) {
|
||||
const woNumber = existing?.woNumber ?? "";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onDismiss} aria-labelledby="duplicate-wo-number-dialog-title">
|
||||
<DialogTitle id="duplicate-wo-number-dialog-title">Duplicate Work Order Number</DialogTitle>
|
||||
<DialogContent>
|
||||
<Text variant="body">
|
||||
Work order number {woNumber} is already assigned to another work order. View the existing
|
||||
record or dismiss to keep editing.
|
||||
</Text>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onDismiss}>Dismiss</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={existing == null}
|
||||
onClick={() => {
|
||||
if (existing != null) {
|
||||
onViewExisting(existing);
|
||||
}
|
||||
}}
|
||||
>
|
||||
View existing
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,12 +12,16 @@ export function buildInlineDraftCreateInput(
|
|||
const location = sites.find((s) => String(s.id) === String(draft.locationId));
|
||||
const locationName = draft.location || draft.site || location?.name || "";
|
||||
|
||||
const hasWeekTarget = Boolean(draft.targetWeek?.trim());
|
||||
const scheduleWeekOnly = draft.scheduleWeekOnly && (hasWeekTarget || Boolean(draft.scheduledOn));
|
||||
|
||||
return {
|
||||
locationId: String(draft.locationId || ""),
|
||||
locationName,
|
||||
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
|
||||
woNumber: draft.woNumber,
|
||||
type: draft.type,
|
||||
type: draft.type || "PM",
|
||||
severity: draft.severity,
|
||||
assigneeId: draft.dispatcherId,
|
||||
assigneeName: assignee?.name ?? "",
|
||||
scheduledOn: draft.scheduledOn,
|
||||
|
|
@ -35,7 +39,7 @@ export function buildInlineDraftCreateInput(
|
|||
pocName: draft.pocName,
|
||||
pocPhone: draft.pocPhone,
|
||||
pocNotes: draft.pocNotes,
|
||||
scheduleWeekOnly: draft.scheduleWeekOnly,
|
||||
scheduleWeekOnly,
|
||||
targetWeek: draft.targetWeek || "",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ type DateCellProps = {
|
|||
targetWeek?: string;
|
||||
onSaveWeek?: (weekStart: string) => void;
|
||||
defaultMode?: "week" | "date";
|
||||
/** When set, shows Clear in the popover so schedule can be removed (e.g. day-group → Unscheduled). */
|
||||
onClear?: () => void;
|
||||
};
|
||||
|
||||
export function DateCell({
|
||||
|
|
@ -26,6 +28,7 @@ export function DateCell({
|
|||
targetWeek = "",
|
||||
onSaveWeek,
|
||||
defaultMode,
|
||||
onClear,
|
||||
}: DateCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [cellMode, setCellMode] = useState<"week" | "date">(
|
||||
|
|
@ -132,6 +135,21 @@ export function DateCell({
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{Boolean(onClear) && (
|
||||
<div className="mt-2.5 flex justify-end border-t border-border pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClear?.();
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="text-[11.5px] font-medium transition-colors"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
× Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,43 +1,30 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
|
||||
import { HighlightText } from "./highlight-text";
|
||||
|
||||
type EditableWONumberCellProps = {
|
||||
value: string;
|
||||
|
||||
rescheduleCount: number;
|
||||
|
||||
carriedOver?: number;
|
||||
|
||||
q?: string;
|
||||
|
||||
currentId: string | number;
|
||||
|
||||
allOrders: WorkOrderTableRow[];
|
||||
|
||||
onSave: (v: string) => void;
|
||||
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
};
|
||||
|
||||
function CounterPill({ label, count }: { label: string; count: number }) {
|
||||
if (count < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (count < 2) return null;
|
||||
return (
|
||||
<span
|
||||
title={label}
|
||||
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
|
||||
style={{
|
||||
background: "var(--color-bg-muted)",
|
||||
|
||||
color: "var(--color-text-secondary)",
|
||||
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
|
|
@ -46,89 +33,134 @@ function CounterPill({ label, count }: { label: string; count: number }) {
|
|||
);
|
||||
}
|
||||
|
||||
type WoNumberEditorProps = {
|
||||
draft: string;
|
||||
invalid: boolean;
|
||||
isChecking: boolean;
|
||||
onDraftChange: (value: string) => void;
|
||||
onCommit: () => void;
|
||||
onCancel: () => void;
|
||||
onBlur: () => void;
|
||||
};
|
||||
|
||||
function WoNumberEditor({
|
||||
draft,
|
||||
invalid,
|
||||
isChecking,
|
||||
onDraftChange,
|
||||
onCommit,
|
||||
onCancel,
|
||||
onBlur,
|
||||
}: WoNumberEditorProps) {
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
disabled={isChecking}
|
||||
inputMode="numeric"
|
||||
value={draft}
|
||||
onChange={(e) => onDraftChange(e.target.value.replace(/\D/g, "").slice(0, 11))}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onCommit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
placeholder="12345678901"
|
||||
aria-label="Work order number"
|
||||
aria-invalid={invalid}
|
||||
className="font-mono focus:outline-none"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "var(--foreground)",
|
||||
background: "var(--color-bg-surface)",
|
||||
border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`,
|
||||
boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`,
|
||||
borderRadius: 4,
|
||||
padding: "2px 4px",
|
||||
width: 120,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveDuplicateWoNumber(
|
||||
draft: string,
|
||||
allOrders: WorkOrderTableRow[],
|
||||
currentId: string | number,
|
||||
) {
|
||||
const local = findDuplicateWoNumberRow(draft, allOrders, currentId);
|
||||
const remote = local ?? (await workOrdersApi.findByWoNumber(draft));
|
||||
return remote && String(remote.id) !== String(currentId) ? remote : undefined;
|
||||
}
|
||||
|
||||
export function EditableWONumberCell({
|
||||
value,
|
||||
|
||||
rescheduleCount,
|
||||
|
||||
carriedOver = 0,
|
||||
|
||||
q,
|
||||
|
||||
currentId,
|
||||
|
||||
allOrders,
|
||||
|
||||
onSave,
|
||||
|
||||
onDuplicateFound,
|
||||
}: EditableWONumberCellProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
setDraft(value);
|
||||
}
|
||||
if (!editing) setDraft(value);
|
||||
}, [value, editing]);
|
||||
|
||||
const findDuplicate = (d: string) =>
|
||||
allOrders.find((o) => o.woNumber === d && String(o.id) !== String(currentId));
|
||||
|
||||
const commit = () => {
|
||||
if (draft === value) {
|
||||
if (draft === value || isChecking) {
|
||||
setEditing(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (draft.length !== 11) {
|
||||
toast("Work order number must be exactly 11 digits");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const duplicate = findDuplicate(draft);
|
||||
|
||||
if (duplicate) {
|
||||
onDuplicateFound?.(duplicate);
|
||||
|
||||
setDraft(value);
|
||||
|
||||
setEditing(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onSave(draft);
|
||||
|
||||
setEditing(false);
|
||||
setIsChecking(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const duplicate = await resolveDuplicateWoNumber(draft, allOrders, currentId);
|
||||
if (duplicate) {
|
||||
if (onDuplicateFound) {
|
||||
onDuplicateFound(duplicate);
|
||||
} else {
|
||||
toast.error(`WO #${duplicate.woNumber} is already assigned to another work order.`);
|
||||
}
|
||||
setDraft(value);
|
||||
return;
|
||||
}
|
||||
onSave(draft);
|
||||
} catch {
|
||||
toast.error("Unable to verify the work order number. Please try again.");
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
setEditing(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setDraft(value);
|
||||
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (isChecking) return;
|
||||
if (draft !== value && draft.length === 11) {
|
||||
const duplicate = findDuplicate(draft);
|
||||
|
||||
if (duplicate) {
|
||||
onDuplicateFound?.(duplicate);
|
||||
|
||||
setDraft(value);
|
||||
|
||||
setEditing(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onSave(draft);
|
||||
commit();
|
||||
return;
|
||||
}
|
||||
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
|
|
@ -137,75 +169,34 @@ export function EditableWONumberCell({
|
|||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
inputMode="numeric"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value.replace(/\D/g, "").slice(0, 11))}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
|
||||
commit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
|
||||
cancel();
|
||||
}
|
||||
}}
|
||||
<WoNumberEditor
|
||||
draft={draft}
|
||||
invalid={invalid}
|
||||
isChecking={isChecking}
|
||||
onDraftChange={setDraft}
|
||||
onCommit={commit}
|
||||
onCancel={cancel}
|
||||
onBlur={handleBlur}
|
||||
placeholder="12345678901"
|
||||
aria-label="Work order number"
|
||||
aria-invalid={invalid}
|
||||
className="font-mono focus:outline-none"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
|
||||
fontWeight: 700,
|
||||
|
||||
color: "var(--foreground)",
|
||||
|
||||
background: "var(--color-bg-surface)",
|
||||
|
||||
border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`,
|
||||
|
||||
boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`,
|
||||
|
||||
borderRadius: 4,
|
||||
|
||||
padding: "2px 4px",
|
||||
|
||||
width: 120,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
setEditing(true);
|
||||
}}
|
||||
className="inline-block cursor-text font-mono transition-colors hover:bg-[var(--color-bg-muted)]"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
|
||||
fontWeight: 700,
|
||||
|
||||
color: value ? "var(--foreground)" : "var(--color-text-muted)",
|
||||
|
||||
borderRadius: 4,
|
||||
|
||||
padding: "2px 4px",
|
||||
}}
|
||||
>
|
||||
{value ? <HighlightText text={value} q={q} /> : "-"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<CounterPill label="Reschedules" count={rescheduleCount} />
|
||||
|
||||
<CounterPill label="Carried over" count={carriedOver} />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import { Tooltip } from "@mui/material";
|
||||
|
||||
import {
|
||||
SEVERITY_LEVELS,
|
||||
SEVERITY_TOKEN_COLOR,
|
||||
type WorkOrderSeverity,
|
||||
} from "@/domain/work-orders/types/work-order-severity";
|
||||
|
||||
type SeverityBadgeProps = {
|
||||
severity: WorkOrderSeverity;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
export function SeverityBadge({ severity, onClick }: SeverityBadgeProps) {
|
||||
const lvl = SEVERITY_LEVELS[severity];
|
||||
const color = SEVERITY_TOKEN_COLOR[lvl.token];
|
||||
const title = `${lvl.description} · ${lvl.deadline} · After hours: ${lvl.afterHours}`;
|
||||
|
||||
return (
|
||||
<Tooltip title={title} enterDelay={400}>
|
||||
<span
|
||||
onClick={onClick}
|
||||
role={onClick ? "button" : undefined}
|
||||
className="inline-flex shrink-0 items-center whitespace-nowrap rounded leading-none"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
color,
|
||||
border: `1px solid ${color}`,
|
||||
padding: "1px 5px",
|
||||
marginLeft: 4,
|
||||
cursor: onClick ? "pointer" : "default",
|
||||
}}
|
||||
>
|
||||
{lvl.label}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
SEVERITY_LEVELS,
|
||||
SEVERITY_TOKEN_COLOR,
|
||||
WORK_ORDER_SEVERITIES,
|
||||
type WorkOrderSeverity,
|
||||
} from "@/domain/work-orders/types/work-order-severity";
|
||||
|
||||
type SeverityOptionsListProps = {
|
||||
value: WorkOrderSeverity | null;
|
||||
onSelect: (severity: WorkOrderSeverity) => void;
|
||||
};
|
||||
|
||||
export function SeverityOptionsList({ value, onSelect }: SeverityOptionsListProps) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
{WORK_ORDER_SEVERITIES.map((n) => {
|
||||
const lvl = SEVERITY_LEVELS[n];
|
||||
const color = SEVERITY_TOKEN_COLOR[lvl.token];
|
||||
const selected = value === n;
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => onSelect(n)}
|
||||
className="flex items-start gap-2 rounded-md text-left transition-colors"
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
border: `1px solid ${selected ? color : "var(--color-border)"}`,
|
||||
background: selected ? "var(--color-bg-muted)" : "transparent",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color,
|
||||
border: `1px solid ${color}`,
|
||||
borderRadius: 4,
|
||||
padding: "1px 5px",
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{lvl.label}
|
||||
</span>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-primary)" }}>
|
||||
{lvl.description}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{lvl.deadline}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SeverityFieldProps = {
|
||||
value: WorkOrderSeverity | null;
|
||||
onSelect: (severity: WorkOrderSeverity) => void;
|
||||
};
|
||||
|
||||
export function SeverityField({ value, onSelect }: SeverityFieldProps) {
|
||||
return (
|
||||
<div>
|
||||
<Text
|
||||
as="p"
|
||||
variant="description"
|
||||
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "4px" }}
|
||||
>
|
||||
Severity (from APM)
|
||||
</Text>
|
||||
<Text
|
||||
as="p"
|
||||
variant="description"
|
||||
sx={{ fontSize: 11, color: "var(--color-text-muted)", margin: "0 0 8px" }}
|
||||
>
|
||||
Transcribe the severity already assigned on the client's ticket — this is not a
|
||||
judgment call.
|
||||
</Text>
|
||||
<SeverityOptionsList value={value} onSelect={onSelect} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,6 +17,8 @@ type SiteCellProps = {
|
|||
pocNotes?: string;
|
||||
sites: LocationOption[];
|
||||
q?: string;
|
||||
/** Inline create: editable POC + Site/POC required to confirm the modal. */
|
||||
createMode?: boolean;
|
||||
onSave: (patch: SitePatch) => void;
|
||||
};
|
||||
|
||||
|
|
@ -31,6 +33,7 @@ export function SiteCell({
|
|||
pocNotes,
|
||||
sites,
|
||||
q,
|
||||
createMode = false,
|
||||
onSave,
|
||||
}: SiteCellProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -70,7 +73,15 @@ export function SiteCell({
|
|||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[12.5px] text-[var(--color-text-muted)]">—</span>
|
||||
<span className="text-[12.5px] text-[var(--color-text-muted)]">
|
||||
{createMode ? (
|
||||
<>
|
||||
Site <span style={{ color: "var(--destructive)" }}>*</span>
|
||||
</>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<HoverChevron />
|
||||
</button>
|
||||
|
|
@ -83,6 +94,7 @@ export function SiteCell({
|
|||
pocPhone={pocPhone}
|
||||
pocNotes={pocNotes}
|
||||
sites={sites}
|
||||
createMode={createMode}
|
||||
onSave={onSave}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -9,37 +9,77 @@ type SiteDialogPocFieldsProps = {
|
|||
pocName: string;
|
||||
pocPhone: string;
|
||||
pocNotes: string;
|
||||
editable?: boolean;
|
||||
showErrors?: boolean;
|
||||
onPocNameChange?: (value: string) => void;
|
||||
onPocPhoneChange?: (value: string) => void;
|
||||
onPocNotesChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
export function SiteDialogPocFields({ pocName, pocPhone, pocNotes }: SiteDialogPocFieldsProps) {
|
||||
export function SiteDialogPocFields({
|
||||
pocName,
|
||||
pocPhone,
|
||||
pocNotes,
|
||||
editable = false,
|
||||
showErrors = false,
|
||||
onPocNameChange,
|
||||
onPocPhoneChange,
|
||||
onPocNotesChange,
|
||||
}: SiteDialogPocFieldsProps) {
|
||||
const nameMissing = showErrors && !pocName.trim();
|
||||
const phoneMissing = showErrors && !pocPhone.trim();
|
||||
|
||||
return (
|
||||
<div className="pt-1">
|
||||
<FieldLabel>Point of Contact</FieldLabel>
|
||||
<Text as="p" className="mb-2 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
Shown from site/work order records. Board edit saves site code only.
|
||||
{editable
|
||||
? "Auto-filled from records — edit if outdated"
|
||||
: "Shown from site/work order records. Board edit saves site code only."}
|
||||
</Text>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={pocName}
|
||||
readOnly
|
||||
placeholder="POC name"
|
||||
className={DLG_INPUT_CLS}
|
||||
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
|
||||
/>
|
||||
<input
|
||||
value={pocPhone}
|
||||
readOnly
|
||||
placeholder="POC phone"
|
||||
className={DLG_INPUT_CLS}
|
||||
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
|
||||
/>
|
||||
<div>
|
||||
<input
|
||||
value={pocName}
|
||||
readOnly={!editable}
|
||||
placeholder="POC name"
|
||||
className={DLG_INPUT_CLS}
|
||||
style={{
|
||||
...DLG_INPUT_STYLE,
|
||||
opacity: editable ? 1 : 0.85,
|
||||
borderColor: nameMissing ? "var(--destructive)" : undefined,
|
||||
}}
|
||||
onChange={(e) => onPocNameChange?.(e.target.value)}
|
||||
/>
|
||||
<Text as="p" variant="error" when={nameMissing} className="mt-1 text-[11px]">
|
||||
Contact name is required
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
value={pocPhone}
|
||||
readOnly={!editable}
|
||||
placeholder="POC phone"
|
||||
className={DLG_INPUT_CLS}
|
||||
style={{
|
||||
...DLG_INPUT_STYLE,
|
||||
opacity: editable ? 1 : 0.85,
|
||||
borderColor: phoneMissing ? "var(--destructive)" : undefined,
|
||||
}}
|
||||
onChange={(e) => onPocPhoneChange?.(e.target.value)}
|
||||
/>
|
||||
<Text as="p" variant="error" when={phoneMissing} className="mt-1 text-[11px]">
|
||||
Contact phone is required
|
||||
</Text>
|
||||
</div>
|
||||
<textarea
|
||||
value={pocNotes}
|
||||
readOnly
|
||||
readOnly={!editable}
|
||||
placeholder="Notes…"
|
||||
rows={2}
|
||||
className={`${DLG_INPUT_CLS} resize-none`}
|
||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit", opacity: 0.85 }}
|
||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit", opacity: editable ? 1 : 0.85 }}
|
||||
onChange={(e) => onPocNotesChange?.(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,30 +1,20 @@
|
|||
import { useEffect, useState, type MouseEvent } from "react";
|
||||
import type { MouseEvent } from "react";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||
import {
|
||||
extractLocationLabel,
|
||||
resolveLocationId,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
|
||||
import { SiteDialogDetailPanel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel";
|
||||
import { SiteDialogPocFields } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields";
|
||||
import {
|
||||
useSiteDialogState,
|
||||
type SitePatch,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
|
||||
|
||||
export type SitePatch = {
|
||||
site: string;
|
||||
location: string;
|
||||
locationId: string | number;
|
||||
pocName: string;
|
||||
pocPhone: string;
|
||||
pocNotes: string;
|
||||
};
|
||||
export type { SitePatch };
|
||||
|
||||
type SiteDialogProps = {
|
||||
open: boolean;
|
||||
|
|
@ -35,6 +25,7 @@ type SiteDialogProps = {
|
|||
pocPhone?: string;
|
||||
pocNotes?: string;
|
||||
sites: LocationOption[];
|
||||
createMode?: boolean;
|
||||
onSave: (patch: SitePatch) => void;
|
||||
};
|
||||
|
||||
|
|
@ -47,62 +38,26 @@ export function SiteDialog({
|
|||
pocPhone = "",
|
||||
pocNotes = "",
|
||||
sites,
|
||||
createMode = false,
|
||||
onSave,
|
||||
}: SiteDialogProps) {
|
||||
const [code, setCode] = useState(value);
|
||||
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
|
||||
const [pn, setPn] = useState(pocName);
|
||||
const [pp, setPp] = useState(pocPhone);
|
||||
const [notes, setNotes] = useState(pocNotes);
|
||||
const [pocFilledFor, setPocFilledFor] = useState("");
|
||||
|
||||
const {
|
||||
data: locationDetail,
|
||||
isLoading: locationDetailLoading,
|
||||
isError: locationDetailError,
|
||||
} = useLocationDetail(open && locId ? locId : undefined);
|
||||
|
||||
const selected = sites.find((s) => String(s.id) === locId);
|
||||
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const resolved = resolveLocationId(locationId, value, sites);
|
||||
setCode(value);
|
||||
setLocId(resolved);
|
||||
setPn(pocName);
|
||||
setPp(pocPhone);
|
||||
setNotes(pocNotes);
|
||||
// Preserve existing WO POC; allow one auto-fill from site detail when POC is empty.
|
||||
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
|
||||
}, [open, value, locationId, pocName, pocPhone, pocNotes, sites]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
|
||||
return;
|
||||
}
|
||||
setPn(locationDetail.contact ?? "");
|
||||
setPp(locationDetail.phone ?? "");
|
||||
setPocFilledFor(locId);
|
||||
}, [open, locId, locationDetail, pocFilledFor]);
|
||||
|
||||
const handlePick = (id: string) => {
|
||||
const next = sites.find((s) => String(s.id) === id);
|
||||
setLocId(id);
|
||||
if (next) {
|
||||
setCode(resolveSiteCode(next));
|
||||
}
|
||||
if (id !== pocFilledFor) {
|
||||
setPocFilledFor("");
|
||||
}
|
||||
};
|
||||
const s = useSiteDialogState({
|
||||
open,
|
||||
onOpenChange,
|
||||
value,
|
||||
locationId,
|
||||
pocName,
|
||||
pocPhone,
|
||||
pocNotes,
|
||||
sites,
|
||||
createMode,
|
||||
onSave,
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
onClose={s.close}
|
||||
maxWidth={false}
|
||||
slotProps={{
|
||||
...dialogBlurBackdropSlotProps,
|
||||
|
|
@ -125,7 +80,7 @@ export function SiteDialog({
|
|||
<div>
|
||||
<FieldLabel>Site</FieldLabel>
|
||||
<WizardFieldSelect
|
||||
value={locId}
|
||||
value={s.locId}
|
||||
placeholder="Select site …"
|
||||
icon={
|
||||
<MapPin
|
||||
|
|
@ -139,45 +94,45 @@ export function SiteDialog({
|
|||
key: String(site.id),
|
||||
label: site.name,
|
||||
}))}
|
||||
onPick={handlePick}
|
||||
onPick={s.handlePick}
|
||||
/>
|
||||
<Text
|
||||
as="p"
|
||||
variant="error"
|
||||
when={Boolean(createMode && s.showErrors && s.siteMissing)}
|
||||
className="mt-1 text-[11px]"
|
||||
>
|
||||
Site is required
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{Boolean(locId) && (
|
||||
{Boolean(s.locId) && (
|
||||
<SiteDialogDetailPanel
|
||||
locationDetail={locationDetail}
|
||||
locationDetailLoading={locationDetailLoading}
|
||||
locationDetailError={locationDetailError}
|
||||
addressPreview={addressPreview}
|
||||
fallbackLabel={selected?.name || code || "—"}
|
||||
locationDetail={s.locationDetail}
|
||||
locationDetailLoading={s.locationDetailLoading}
|
||||
locationDetailError={s.locationDetailError}
|
||||
addressPreview={s.addressPreview}
|
||||
fallbackLabel={s.selected?.name || s.code || "—"}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SiteDialogPocFields pocName={pn} pocPhone={pp} pocNotes={notes} />
|
||||
<SiteDialogPocFields
|
||||
pocName={s.pn}
|
||||
pocPhone={s.pp}
|
||||
pocNotes={s.notes}
|
||||
editable={createMode}
|
||||
showErrors={createMode && s.showErrors}
|
||||
onPocNameChange={s.setPn}
|
||||
onPocPhoneChange={s.setPp}
|
||||
onPocNotesChange={s.setNotes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="min-h-0 px-2 py-1 text-[12px]"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<Button variant="ghost" className="min-h-0 px-2 py-1 text-[12px]" onClick={s.close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
onClick={() => {
|
||||
onSave({
|
||||
site: code || (selected ? resolveSiteCode(selected) : ""),
|
||||
location: selected ? extractLocationLabel(selected) : "",
|
||||
locationId: locId,
|
||||
pocName: pn,
|
||||
pocPhone: pp,
|
||||
pocNotes: notes,
|
||||
});
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<Button className="min-h-0 px-3 py-1 text-[12px]" onClick={s.attemptSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,49 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { Popover } from "@mui/material";
|
||||
|
||||
import {
|
||||
typeNeedsSeverity,
|
||||
type WorkOrderSeverity,
|
||||
} from "@/domain/work-orders/types/work-order-severity";
|
||||
import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
import { HoverChevron } from "./hover-chevron";
|
||||
|
||||
import { SeverityBadge } from "./severity-badge";
|
||||
import { TypeSeverityModal } from "./type-severity-modal";
|
||||
import { WoTableTypeLabel } from "./wo-table-type-label";
|
||||
|
||||
type TypeCellProps = {
|
||||
value: WOType | "";
|
||||
|
||||
severity?: WorkOrderSeverity | null;
|
||||
q?: string;
|
||||
|
||||
onSave: (v: WOType) => void;
|
||||
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
|
||||
};
|
||||
|
||||
export function TypeCell({ value, q, onSave }: TypeCellProps) {
|
||||
export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalSeed, setModalSeed] = useState<WOType | "">(value);
|
||||
const [resetSeverity, setResetSeverity] = useState(false);
|
||||
|
||||
function pick(t: WOType) {
|
||||
setAnchorEl(null);
|
||||
const needsSeverity = typeNeedsSeverity(t);
|
||||
const switchingSeverityType =
|
||||
needsSeverity && value !== "" && typeNeedsSeverity(value) && t !== value;
|
||||
|
||||
if (needsSeverity && (severity == null || switchingSeverityType)) {
|
||||
setModalSeed(t);
|
||||
setResetSeverity(Boolean(switchingSeverityType && severity != null));
|
||||
setModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
onSave(t, needsSeverity ? severity : null);
|
||||
}
|
||||
|
||||
const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -27,7 +51,6 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) {
|
|||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
|
||||
|
|
@ -37,7 +60,17 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) {
|
|||
) : (
|
||||
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
|
||||
)}
|
||||
|
||||
{Boolean(showSeverityBadge && severity != null) && (
|
||||
<SeverityBadge
|
||||
severity={severity!}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setModalSeed(value);
|
||||
setResetSeverity(false);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<HoverChevron />
|
||||
</button>
|
||||
|
||||
|
|
@ -53,19 +86,22 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) {
|
|||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSave(t);
|
||||
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
onClick={() => pick(t)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
|
||||
>
|
||||
<WoTableTypeLabel type={t} />
|
||||
|
||||
{value === t && <Check size={10} className="ml-auto text-[var(--color-primary)]" />}
|
||||
</button>
|
||||
))}
|
||||
</Popover>
|
||||
|
||||
<TypeSeverityModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
type={modalSeed}
|
||||
severity={resetSeverity ? null : severity}
|
||||
onSave={onSave}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
typeNeedsSeverity,
|
||||
type WorkOrderSeverity,
|
||||
} from "@/domain/work-orders/types/work-order-severity";
|
||||
import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
import { SeverityField } from "./severity-options-list";
|
||||
import { WoTableTypeLabel } from "./wo-table-type-label";
|
||||
|
||||
type TypeSeverityModalProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
type: WOType | "";
|
||||
severity: WorkOrderSeverity | null;
|
||||
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
|
||||
};
|
||||
|
||||
export function TypeSeverityModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
type,
|
||||
severity,
|
||||
onSave,
|
||||
}: TypeSeverityModalProps) {
|
||||
const [draftType, setDraftType] = useState<WOType | "">(type);
|
||||
const [draftSeverity, setDraftSeverity] = useState<WorkOrderSeverity | null>(severity);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraftType(type);
|
||||
setDraftSeverity(severity);
|
||||
}
|
||||
}, [open, type, severity]);
|
||||
|
||||
const needsSeverity = typeNeedsSeverity(draftType);
|
||||
|
||||
function pickType(t: WOType) {
|
||||
if (typeNeedsSeverity(t)) {
|
||||
if (t !== draftType) {
|
||||
setDraftSeverity(null);
|
||||
}
|
||||
setDraftType(t);
|
||||
return;
|
||||
}
|
||||
onSave(t, null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (!draftType || (needsSeverity && draftSeverity == null)) {
|
||||
return;
|
||||
}
|
||||
onSave(draftType, needsSeverity ? draftSeverity : null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
const canConfirm = Boolean(draftType) && (!needsSeverity || draftSeverity != null);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: { borderRadius: 2, background: "var(--color-bg-surface)" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
fontFamily: "var(--font-display)",
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
}}
|
||||
>
|
||||
Type & severity
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Text
|
||||
as="p"
|
||||
variant="description"
|
||||
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "6px" }}
|
||||
>
|
||||
Type
|
||||
</Text>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{WO_TYPES.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => pickType(t)}
|
||||
className="rounded-full transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
border: `1px solid ${
|
||||
draftType === t ? "var(--color-primary)" : "var(--color-border)"
|
||||
}`,
|
||||
background: draftType === t ? "var(--color-primary-tint)" : "transparent",
|
||||
}}
|
||||
>
|
||||
<WoTableTypeLabel type={t} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{Boolean(needsSeverity) && (
|
||||
<SeverityField value={draftSeverity} onSelect={setDraftSeverity} />
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ padding: "12px 20px", gap: 1 }}>
|
||||
<Button variant="text" size="small" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={confirm}
|
||||
disabled={!canConfirm}
|
||||
sx={
|
||||
canConfirm
|
||||
? undefined
|
||||
: {
|
||||
opacity: 0.5,
|
||||
cursor: "not-allowed",
|
||||
"&:hover": { backgroundColor: "var(--color-primary)", opacity: 0.5 },
|
||||
}
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
extractLocationLabel,
|
||||
resolveLocationId,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
|
||||
|
||||
export type SitePatch = {
|
||||
site: string;
|
||||
location: string;
|
||||
locationId: string | number;
|
||||
pocName: string;
|
||||
pocPhone: string;
|
||||
pocNotes: string;
|
||||
};
|
||||
|
||||
type UseSiteDialogStateArgs = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
value: string;
|
||||
locationId: string | number;
|
||||
pocName: string;
|
||||
pocPhone: string;
|
||||
pocNotes: string;
|
||||
sites: LocationOption[];
|
||||
createMode: boolean;
|
||||
onSave: (patch: SitePatch) => void;
|
||||
};
|
||||
|
||||
export function useSiteDialogState({
|
||||
open,
|
||||
onOpenChange,
|
||||
value,
|
||||
locationId,
|
||||
pocName,
|
||||
pocPhone,
|
||||
pocNotes,
|
||||
sites,
|
||||
createMode,
|
||||
onSave,
|
||||
}: UseSiteDialogStateArgs) {
|
||||
const [code, setCode] = useState(value);
|
||||
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
|
||||
const [pn, setPn] = useState(pocName);
|
||||
const [pp, setPp] = useState(pocPhone);
|
||||
const [notes, setNotes] = useState(pocNotes);
|
||||
const [pocFilledFor, setPocFilledFor] = useState("");
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
|
||||
const {
|
||||
data: locationDetail,
|
||||
isLoading: locationDetailLoading,
|
||||
isError: locationDetailError,
|
||||
} = useLocationDetail(open && locId ? locId : undefined);
|
||||
|
||||
const selected = sites.find((s) => String(s.id) === locId);
|
||||
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
|
||||
const siteMissing = !locId;
|
||||
const pocMissing = !pn.trim() || !pp.trim();
|
||||
const canConfirm = !siteMissing && (!createMode || !pocMissing);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const resolved = resolveLocationId(locationId, value, sites);
|
||||
setCode(value);
|
||||
setLocId(resolved);
|
||||
setPn(pocName);
|
||||
setPp(pocPhone);
|
||||
setNotes(pocNotes);
|
||||
setShowErrors(false);
|
||||
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
|
||||
}, [open, value, locationId, pocName, pocPhone, pocNotes, sites]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
|
||||
return;
|
||||
}
|
||||
// Only autofill empty fields so typed createMode POC edits are not overwritten.
|
||||
setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
|
||||
setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
|
||||
setPocFilledFor(locId);
|
||||
}, [open, locId, locationDetail, pocFilledFor]);
|
||||
|
||||
const handlePick = (id: string) => {
|
||||
const next = sites.find((s) => String(s.id) === id);
|
||||
const siteChanged = id !== locId;
|
||||
setLocId(id);
|
||||
if (next) {
|
||||
setCode(resolveSiteCode(next));
|
||||
}
|
||||
if (siteChanged) {
|
||||
setPn("");
|
||||
setPp("");
|
||||
setPocFilledFor("");
|
||||
} else if (id !== pocFilledFor) {
|
||||
setPocFilledFor("");
|
||||
}
|
||||
};
|
||||
|
||||
const attemptSave = () => {
|
||||
if (createMode && !canConfirm) {
|
||||
setShowErrors(true);
|
||||
return;
|
||||
}
|
||||
onSave({
|
||||
site: code || (selected ? resolveSiteCode(selected) : ""),
|
||||
location: selected ? extractLocationLabel(selected) : "",
|
||||
locationId: locId,
|
||||
pocName: pn,
|
||||
pocPhone: pp,
|
||||
pocNotes: notes,
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return {
|
||||
locId,
|
||||
pn,
|
||||
pp,
|
||||
notes,
|
||||
showErrors,
|
||||
locationDetail,
|
||||
locationDetailLoading,
|
||||
locationDetailError,
|
||||
selected,
|
||||
addressPreview,
|
||||
siteMissing,
|
||||
canConfirm,
|
||||
code,
|
||||
handlePick,
|
||||
attemptSave,
|
||||
setPn,
|
||||
setPp,
|
||||
setNotes,
|
||||
close: () => onOpenChange(false),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
type InlineCreateFieldProps = {
|
||||
showError?: boolean;
|
||||
error?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/** Bordered create-cell wrapper; error ring + helper reveal after a failed Save attempt. */
|
||||
export function InlineCreateField({ showError = false, error, children }: InlineCreateFieldProps) {
|
||||
return (
|
||||
<div className="flex w-full min-w-0 flex-col gap-1">
|
||||
<div
|
||||
className="flex w-full min-w-0 items-center"
|
||||
style={{
|
||||
minHeight: 34,
|
||||
borderRadius: 6,
|
||||
padding: "0 8px",
|
||||
background: "var(--color-bg-surface)",
|
||||
border: `1px solid ${showError ? "var(--destructive)" : "var(--color-border)"}`,
|
||||
boxShadow: showError ? "0 0 0 1px var(--destructive)" : "none",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<Text as="p" variant="error" when={Boolean(showError && error)} className="m-0 text-[11px]">
|
||||
{error}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
|
||||
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
|
||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||
import { buildInlineDraftCreateInput } from "./build-inline-draft-create-input";
|
||||
|
||||
type UseWoTableInlineCreateArgs = {
|
||||
tableData: ReturnTypeOfUseWorkOrdersTableData;
|
||||
users: WorkOrderUser[];
|
||||
sites: LocationOption[];
|
||||
onDuplicateFound: (row: WorkOrderTableRow) => void;
|
||||
};
|
||||
|
||||
export function useWoTableInlineCreate({
|
||||
tableData,
|
||||
users,
|
||||
sites,
|
||||
onDuplicateFound,
|
||||
}: UseWoTableInlineCreateArgs) {
|
||||
const [isCheckingInlineDuplicate, setIsCheckingInlineDuplicate] = useState(false);
|
||||
const createMutation = useCreateWorkOrderFromWizard(() => {
|
||||
tableData.cancelDraft();
|
||||
});
|
||||
|
||||
const handleInlineSave = () => {
|
||||
void (async () => {
|
||||
const draftSnapshot = tableData.draft;
|
||||
const woNumber = draftSnapshot.woNumber.trim();
|
||||
if (woNumber) {
|
||||
setIsCheckingInlineDuplicate(true);
|
||||
try {
|
||||
const duplicate =
|
||||
findDuplicateWoNumberRow(woNumber, tableData.rows, "draft") ??
|
||||
(await workOrdersApi.findByWoNumber(woNumber));
|
||||
if (duplicate && String(duplicate.id) !== "draft") {
|
||||
onDuplicateFound(duplicate);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
toast.error("Unable to verify the work order number. Please try again.");
|
||||
return;
|
||||
} finally {
|
||||
setIsCheckingInlineDuplicate(false);
|
||||
}
|
||||
}
|
||||
createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites));
|
||||
})();
|
||||
};
|
||||
|
||||
return {
|
||||
handleInlineSave,
|
||||
saving: createMutation.isPending || isCheckingInlineDuplicate,
|
||||
};
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ type UseWoTableRowHandlersOptions = {
|
|||
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
|
||||
onScheduleHighlight: (id: string | number | null) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
};
|
||||
|
||||
export function useWoTableRowHandlers({
|
||||
|
|
@ -30,6 +31,7 @@ export function useWoTableRowHandlers({
|
|||
onOpenMedia,
|
||||
onPatch,
|
||||
onScheduleHighlight,
|
||||
onDuplicateFound,
|
||||
}: UseWoTableRowHandlersOptions): WoTableRowHandlers {
|
||||
const {
|
||||
dragState: dragStateRef,
|
||||
|
|
@ -97,8 +99,7 @@ export function useWoTableRowHandlers({
|
|||
});
|
||||
},
|
||||
onDuplicateFound: (existing: WorkOrderTableRow) => {
|
||||
tableData.setSelectedId(existing.id);
|
||||
onOpenSlideOver(existing, false);
|
||||
onDuplicateFound?.(existing);
|
||||
},
|
||||
onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false),
|
||||
onPastDueAttempt: (id: string | number) => onScheduleHighlight(id),
|
||||
|
|
@ -138,6 +139,7 @@ export function useWoTableRowHandlers({
|
|||
onOpenMedia,
|
||||
onPatch,
|
||||
onScheduleHighlight,
|
||||
onDuplicateFound,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ export const WO_TABLE_COLS = [
|
|||
{ key: "appt", label: "APPT TIME", w: 128 },
|
||||
{ key: "status", label: "STATUS", w: 148 },
|
||||
{ key: "doc", label: "COMP DOC", w: 84 },
|
||||
{ key: "completed", label: "COMPLETED DATE", w: 130 },
|
||||
{ key: "actions", label: "", w: 100 },
|
||||
] as const;
|
||||
|
||||
export const WO_TABLE_MIN_WIDTH = 1504;
|
||||
export const WO_TABLE_MIN_WIDTH = 1634;
|
||||
export const WO_TABLE_ROW_HEIGHT = 52;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import type { RefObject } from "react";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
import type {
|
||||
WorkOrderTableDraft,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
||||
import { SiteCell } from "./cells/site-cell";
|
||||
import { InlineCreateField } from "./inline-create-field";
|
||||
|
||||
type WoTableInlineIdentityCellsProps = {
|
||||
draft: WorkOrderTableDraft;
|
||||
sites: LocationOption[];
|
||||
sitePlace?: { city: string; state: string };
|
||||
allRows: { id: string | number; woNumber: string }[];
|
||||
siteCellRef: RefObject<HTMLTableCellElement | null>;
|
||||
woCellRef: RefObject<HTMLTableCellElement | null>;
|
||||
siteShowError: boolean;
|
||||
siteError: string;
|
||||
woShowError: boolean;
|
||||
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
};
|
||||
|
||||
export function WoTableInlineIdentityCells({
|
||||
draft,
|
||||
sites,
|
||||
sitePlace,
|
||||
allRows,
|
||||
siteCellRef,
|
||||
woCellRef,
|
||||
siteShowError,
|
||||
siteError,
|
||||
woShowError,
|
||||
onPatch,
|
||||
onDuplicateFound,
|
||||
}: WoTableInlineIdentityCellsProps) {
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
ref={siteCellRef}
|
||||
className="group/cell"
|
||||
style={{ padding: "8px 6px 0", verticalAlign: "top" }}
|
||||
>
|
||||
<InlineCreateField showError={siteShowError} error={siteError}>
|
||||
<SiteCell
|
||||
value={draft.site}
|
||||
location={draft.location}
|
||||
locationId={draft.locationId}
|
||||
city={sitePlace?.city}
|
||||
state={sitePlace?.state}
|
||||
pocName={draft.pocName}
|
||||
pocPhone={draft.pocPhone}
|
||||
pocNotes={draft.pocNotes}
|
||||
sites={sites}
|
||||
createMode
|
||||
onSave={(p) =>
|
||||
onPatch({
|
||||
site: p.site,
|
||||
location: p.location,
|
||||
locationId: p.locationId,
|
||||
pocName: p.pocName,
|
||||
pocPhone: p.pocPhone,
|
||||
pocNotes: p.pocNotes,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</InlineCreateField>
|
||||
</td>
|
||||
|
||||
<td
|
||||
ref={woCellRef}
|
||||
style={{ padding: "8px 6px 0", verticalAlign: "top", whiteSpace: "nowrap" }}
|
||||
>
|
||||
<InlineCreateField showError={woShowError} error="Work order number is required">
|
||||
<EditableWONumberCell
|
||||
value={draft.woNumber}
|
||||
rescheduleCount={0}
|
||||
currentId="draft"
|
||||
allOrders={allRows as never[]}
|
||||
onSave={(v) => onPatch({ woNumber: v })}
|
||||
onDuplicateFound={onDuplicateFound}
|
||||
/>
|
||||
</InlineCreateField>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { Check, X } from "lucide-react";
|
|||
type WoTableInlineRowActionsProps = {
|
||||
canSave: boolean;
|
||||
saving: boolean;
|
||||
saveHint: string;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
|
@ -11,6 +12,7 @@ type WoTableInlineRowActionsProps = {
|
|||
export function WoTableInlineRowActions({
|
||||
canSave,
|
||||
saving,
|
||||
saveHint,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: WoTableInlineRowActionsProps) {
|
||||
|
|
@ -19,11 +21,14 @@ export function WoTableInlineRowActions({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={!canSave}
|
||||
title={saving ? "Saving…" : canSave ? "Save" : "Schedule On is required"}
|
||||
disabled={saving}
|
||||
title={saveHint}
|
||||
aria-label={saving ? "Saving work order" : "Save work order"}
|
||||
className="rounded p-1 transition-colors disabled:opacity-30"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
style={{
|
||||
color: "var(--color-primary)",
|
||||
...(canSave || saving ? {} : { opacity: 0.5, cursor: "not-allowed" }),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (canSave) {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-primary-tint)";
|
||||
|
|
|
|||
|
|
@ -1,20 +1,23 @@
|
|||
import type { RefObject } from "react";
|
||||
import type {
|
||||
LocationOption,
|
||||
VendorDropdownItem,
|
||||
WorkOrderUser,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type {
|
||||
WorkOrderTableDraft,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||
import { ApptTimeCell } from "./cells/appt-time-cell";
|
||||
import { DateCell } from "./cells/date-cell";
|
||||
import { DueDateCell } from "./cells/due-date-cell";
|
||||
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
||||
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||
import { SiteCell } from "./cells/site-cell";
|
||||
import { StatusCell } from "./cells/status-cell";
|
||||
import { TypeCell } from "./cells/type-cell";
|
||||
import { VendorCell } from "./cells/vendor-cell";
|
||||
import { toInlineDraftRow } from "./to-inline-draft-row";
|
||||
import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells";
|
||||
|
||||
type WoTableInlineRowCellsProps = {
|
||||
draft: WorkOrderTableDraft;
|
||||
|
|
@ -25,9 +28,41 @@ type WoTableInlineRowCellsProps = {
|
|||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
allRows: { id: string | number; woNumber: string }[];
|
||||
siteCellRef: RefObject<HTMLTableCellElement | null>;
|
||||
woCellRef: RefObject<HTMLTableCellElement | null>;
|
||||
siteShowError: boolean;
|
||||
siteError: string;
|
||||
woShowError: boolean;
|
||||
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
};
|
||||
|
||||
function InlineTypeSeverityCell({
|
||||
draft,
|
||||
onPatch,
|
||||
}: {
|
||||
draft: WorkOrderTableDraft;
|
||||
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||
}) {
|
||||
return (
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<TypeCell
|
||||
value={draft.type}
|
||||
severity={draft.severity}
|
||||
onSave={(t, sev) => onPatch({ type: t, severity: sev })}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineMutedDashCell() {
|
||||
return (
|
||||
<td style={{ padding: "0 14px" }}>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
export function WoTableInlineRowCells({
|
||||
draft,
|
||||
weekDefault,
|
||||
|
|
@ -37,49 +72,34 @@ export function WoTableInlineRowCells({
|
|||
vendors,
|
||||
pmTypes,
|
||||
allRows,
|
||||
siteCellRef,
|
||||
woCellRef,
|
||||
siteShowError,
|
||||
siteError,
|
||||
woShowError,
|
||||
onPatch,
|
||||
onDuplicateFound,
|
||||
}: WoTableInlineRowCellsProps) {
|
||||
const draftRow = toInlineDraftRow(draft);
|
||||
const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek);
|
||||
|
||||
return (
|
||||
<>
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<SiteCell
|
||||
value={draft.site}
|
||||
location={draft.location}
|
||||
locationId={draft.locationId}
|
||||
city={sitePlace?.city}
|
||||
state={sitePlace?.state}
|
||||
pocName={draft.pocName}
|
||||
pocPhone={draft.pocPhone}
|
||||
pocNotes={draft.pocNotes}
|
||||
sites={sites}
|
||||
onSave={(p) =>
|
||||
onPatch({
|
||||
site: p.site,
|
||||
location: p.location,
|
||||
locationId: p.locationId,
|
||||
pocName: p.pocName,
|
||||
pocPhone: p.pocPhone,
|
||||
pocNotes: p.pocNotes,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<WoTableInlineIdentityCells
|
||||
draft={draft}
|
||||
sites={sites}
|
||||
sitePlace={sitePlace}
|
||||
allRows={allRows}
|
||||
siteCellRef={siteCellRef}
|
||||
woCellRef={woCellRef}
|
||||
siteShowError={siteShowError}
|
||||
siteError={siteError}
|
||||
woShowError={woShowError}
|
||||
onPatch={onPatch}
|
||||
onDuplicateFound={onDuplicateFound}
|
||||
/>
|
||||
|
||||
<td style={{ padding: "0 14px" }}>
|
||||
<EditableWONumberCell
|
||||
value={draft.woNumber}
|
||||
rescheduleCount={0}
|
||||
currentId="draft"
|
||||
allOrders={allRows as never[]}
|
||||
onSave={(v) => onPatch({ woNumber: v })}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<TypeCell value={draft.type} onSave={(t) => onPatch({ type: t })} />
|
||||
</td>
|
||||
<InlineTypeSeverityCell draft={draft} onPatch={onPatch} />
|
||||
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<AssignedToCell
|
||||
|
|
@ -98,11 +118,15 @@ export function WoTableInlineRowCells({
|
|||
allowWeekMode
|
||||
targetWeek={draft.targetWeek}
|
||||
defaultMode={weekDefault ? "week" : "date"}
|
||||
highlight={!draft.scheduledOn && !draft.targetWeek}
|
||||
onSave={(date) => onPatch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
|
||||
onSaveWeek={(week) =>
|
||||
onPatch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
|
||||
}
|
||||
onClear={
|
||||
hasSchedule
|
||||
? () => onPatch({ scheduledOn: "", targetWeek: "", scheduleWeekOnly: false })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
|
||||
|
|
@ -170,9 +194,8 @@ export function WoTableInlineRowCells({
|
|||
/>
|
||||
</td>
|
||||
|
||||
<td style={{ padding: "0 14px" }}>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
|
||||
</td>
|
||||
<InlineMutedDashCell />
|
||||
<InlineMutedDashCell />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useRef, useState, type RefObject } from "react";
|
||||
import { Flag } from "lucide-react";
|
||||
|
||||
import type {
|
||||
|
|
@ -5,10 +6,17 @@ import type {
|
|||
VendorDropdownItem,
|
||||
WorkOrderUser,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
|
||||
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
import type {
|
||||
WorkOrderTableDraft,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
draftHasScheduleDate,
|
||||
getInlineCreateMissing,
|
||||
inlineCreateSaveHint,
|
||||
inlineCreateSiteError,
|
||||
} from "@/domain/work-orders/utils/get-inline-create-missing";
|
||||
|
||||
import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
|
||||
import { WoTableInlineRowCells } from "./wo-table-inline-row-cells";
|
||||
|
|
@ -16,72 +24,88 @@ import { WoTableInlineRowActions } from "./wo-table-inline-row-actions";
|
|||
|
||||
type WoTableInlineRowProps = {
|
||||
draft: WorkOrderTableDraft;
|
||||
|
||||
draftDayKey: string;
|
||||
|
||||
users: WorkOrderUser[];
|
||||
|
||||
sites: LocationOption[];
|
||||
|
||||
sitePlace?: { city: string; state: string };
|
||||
|
||||
vendors: VendorDropdownItem[];
|
||||
|
||||
pmTypes: string[];
|
||||
|
||||
allRows: { id: string | number; woNumber: string }[];
|
||||
|
||||
saving?: boolean;
|
||||
|
||||
onChange: (draft: WorkOrderTableDraft) => void;
|
||||
|
||||
onSave: () => void;
|
||||
|
||||
onCancel: () => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
};
|
||||
|
||||
const PRE_SCHEDULE = new Set(["Incomplete", "Pending", ""]);
|
||||
|
||||
function withScheduleStatusSync(next: WorkOrderTableDraft): WorkOrderTableDraft {
|
||||
const dated = draftHasScheduleDate(next);
|
||||
if (dated && PRE_SCHEDULE.has(next.status)) {
|
||||
return { ...next, status: "Scheduled" };
|
||||
}
|
||||
if (!dated && next.status === "Scheduled") {
|
||||
return { ...next, status: "Pending" };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function WoTableInlineRow({
|
||||
draft,
|
||||
|
||||
draftDayKey,
|
||||
|
||||
users,
|
||||
|
||||
sites,
|
||||
|
||||
sitePlace,
|
||||
|
||||
vendors,
|
||||
|
||||
pmTypes,
|
||||
|
||||
allRows,
|
||||
|
||||
saving = false,
|
||||
|
||||
onChange,
|
||||
|
||||
onSave,
|
||||
|
||||
onCancel,
|
||||
onDuplicateFound,
|
||||
}: WoTableInlineRowProps) {
|
||||
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
|
||||
const missing = getInlineCreateMissing(draft);
|
||||
const canSave = !saving && missing.length === 0;
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
const siteCellRef = useRef<HTMLTableCellElement>(null);
|
||||
const woCellRef = useRef<HTMLTableCellElement>(null);
|
||||
|
||||
const canSave =
|
||||
!saving && (weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn);
|
||||
const siteCellMissing = missing.some((f) => f === "site" || f === "pocName" || f === "pocPhone");
|
||||
const siteShowError = showErrors && siteCellMissing;
|
||||
const woShowError = showErrors && missing.includes("woNumber");
|
||||
|
||||
const patch = (p: Partial<WorkOrderTableDraft>) => onChange({ ...draft, ...p });
|
||||
const patch = (p: Partial<WorkOrderTableDraft>) =>
|
||||
onChange(withScheduleStatusSync({ ...draft, ...p }));
|
||||
|
||||
const focusCell = (r: RefObject<HTMLTableCellElement | null>) =>
|
||||
r.current?.querySelector<HTMLElement>("input, button, [tabindex]")?.focus();
|
||||
|
||||
const attemptSave = () => {
|
||||
if (saving) {
|
||||
return;
|
||||
}
|
||||
if (canSave) {
|
||||
onSave();
|
||||
return;
|
||||
}
|
||||
setShowErrors(true);
|
||||
if (siteCellMissing) {
|
||||
focusCell(siteCellRef);
|
||||
} else if (missing.includes("woNumber")) {
|
||||
focusCell(woCellRef);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<tr
|
||||
aria-busy={saving || undefined}
|
||||
style={{
|
||||
height: WO_TABLE_ROW_HEIGHT,
|
||||
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
|
||||
borderLeft: "2px solid var(--color-primary)",
|
||||
|
||||
background: "var(--color-primary-tint)",
|
||||
opacity: saving ? 0.7 : 1,
|
||||
pointerEvents: saving ? "none" : undefined,
|
||||
|
|
@ -102,14 +126,21 @@ export function WoTableInlineRow({
|
|||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
allRows={allRows}
|
||||
siteCellRef={siteCellRef}
|
||||
woCellRef={woCellRef}
|
||||
siteShowError={siteShowError}
|
||||
siteError={inlineCreateSiteError(missing)}
|
||||
woShowError={woShowError}
|
||||
onPatch={patch}
|
||||
onDuplicateFound={onDuplicateFound}
|
||||
/>
|
||||
|
||||
<td style={{ padding: "0 4px" }}>
|
||||
<WoTableInlineRowActions
|
||||
canSave={canSave}
|
||||
saving={saving}
|
||||
onSave={onSave}
|
||||
saveHint={saving ? "Saving…" : inlineCreateSaveHint(missing)}
|
||||
onSave={attemptSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -83,7 +83,12 @@ export function WoTableRowCells({
|
|||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TypeCell value={row.type} q={search} onSave={(t) => onPatchRow({ type: t })} />
|
||||
<TypeCell
|
||||
value={row.type}
|
||||
severity={row.severity}
|
||||
q={search}
|
||||
onSave={(t, sev) => onPatchRow({ type: t, severity: sev })}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className="group/cell"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
|||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { ApptTimeCell } from "./cells/appt-time-cell";
|
||||
import { CompDocCell } from "./cells/comp-doc-cell";
|
||||
import { DateCell } from "./cells/date-cell";
|
||||
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||
import { StatusCell } from "./cells/status-cell";
|
||||
import { VendorCell } from "./cells/vendor-cell";
|
||||
|
|
@ -102,6 +103,17 @@ export function WoTableRowServiceCells({
|
|||
onOpenMedia={() => handlers.onOpenMedia(row)}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className="group/cell"
|
||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DateCell
|
||||
value={row.completedDate}
|
||||
q={search}
|
||||
onSave={(date) => onPatchRow({ completedDate: date })}
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ import type {
|
|||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
|
||||
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
|
||||
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
|
||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||
import { buildInlineDraftCreateInput } from "./build-inline-draft-create-input";
|
||||
import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup";
|
||||
import { useWoTableInlineCreate } from "./use-wo-table-inline-create";
|
||||
import { useWoTableRowHandlers } from "./use-wo-table-row-handlers";
|
||||
import { WoTableBody } from "./wo-table-body";
|
||||
import { WoTableHead } from "./wo-table-head";
|
||||
|
|
@ -55,10 +55,14 @@ export function WoTable({
|
|||
onPatch,
|
||||
}: WoTableProps) {
|
||||
const [dueDateHighlightId, setDueDateHighlightId] = useState<string | number | null>(null);
|
||||
const [duplicateRow, setDuplicateRow] = useState<WorkOrderTableRow | null>(null);
|
||||
const { data: locationSites = [] } = useLocationSites();
|
||||
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
|
||||
const createMutation = useCreateWorkOrderFromWizard(() => {
|
||||
tableData.cancelDraft();
|
||||
const { handleInlineSave, saving } = useWoTableInlineCreate({
|
||||
tableData,
|
||||
users,
|
||||
sites,
|
||||
onDuplicateFound: setDuplicateRow,
|
||||
});
|
||||
|
||||
const handlers = useWoTableRowHandlers({
|
||||
|
|
@ -70,6 +74,7 @@ export function WoTable({
|
|||
onOpenMedia,
|
||||
onPatch,
|
||||
onScheduleHighlight: setDueDateHighlightId,
|
||||
onDuplicateFound: setDuplicateRow,
|
||||
});
|
||||
|
||||
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
|
||||
|
|
@ -107,12 +112,11 @@ export function WoTable({
|
|||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
allRows={tableData.rows}
|
||||
saving={createMutation.isPending}
|
||||
saving={saving}
|
||||
onChange={tableData.setDraft}
|
||||
onSave={() =>
|
||||
createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites))
|
||||
}
|
||||
onSave={handleInlineSave}
|
||||
onCancel={tableData.cancelDraft}
|
||||
onDuplicateFound={setDuplicateRow}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -151,21 +155,33 @@ export function WoTable({
|
|||
}
|
||||
|
||||
return (
|
||||
<WoTableShell>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
minWidth: WO_TABLE_MIN_WIDTH,
|
||||
borderCollapse: "collapse",
|
||||
<>
|
||||
<WoTableShell>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
minWidth: WO_TABLE_MIN_WIDTH,
|
||||
borderCollapse: "collapse",
|
||||
}}
|
||||
>
|
||||
<WoTableHead />
|
||||
<WoTableBody
|
||||
tableData={tableData}
|
||||
renderRow={renderRow}
|
||||
renderInlineRow={renderInlineRow}
|
||||
/>
|
||||
</table>
|
||||
</WoTableShell>
|
||||
|
||||
<DuplicateWoNumberDialog
|
||||
open={duplicateRow != null}
|
||||
existing={duplicateRow}
|
||||
onDismiss={() => setDuplicateRow(null)}
|
||||
onViewExisting={(row) => {
|
||||
setDuplicateRow(null);
|
||||
onOpenSlideOver(row, false);
|
||||
}}
|
||||
>
|
||||
<WoTableHead />
|
||||
<WoTableBody
|
||||
tableData={tableData}
|
||||
renderRow={renderRow}
|
||||
renderInlineRow={renderInlineRow}
|
||||
/>
|
||||
</table>
|
||||
</WoTableShell>
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
||||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
|
||||
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
|
||||
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order-common";
|
||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order-list";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
type WorkOrdersListOverlaysProps = {
|
||||
wizardOpen: boolean;
|
||||
onWizardOpenChange: (open: boolean) => void;
|
||||
users: WorkOrderUser[];
|
||||
sites: LocationOption[];
|
||||
pmTypes: string[];
|
||||
vendorTechs: string[];
|
||||
advOpen: boolean;
|
||||
onAdvOpenChange: (open: boolean) => void;
|
||||
advInitial: WorkOrderAdvancedFilters;
|
||||
onApplyAdvanced: (next: WorkOrderAdvancedFilters) => void;
|
||||
onClearAdvanced: () => void;
|
||||
activeDocRow: WorkOrderTableRow | null;
|
||||
onDocOpenChange: (open: boolean) => void;
|
||||
completionDocs: Record<string, CompDocData>;
|
||||
onSaveCompDoc: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
|
||||
onDateCompletedChange: (date: string) => void | Promise<void>;
|
||||
activeMediaRow: WorkOrderTableRow | null;
|
||||
onMediaOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function WorkOrdersListOverlays({
|
||||
wizardOpen,
|
||||
onWizardOpenChange,
|
||||
users,
|
||||
sites,
|
||||
pmTypes,
|
||||
vendorTechs,
|
||||
advOpen,
|
||||
onAdvOpenChange,
|
||||
advInitial,
|
||||
onApplyAdvanced,
|
||||
onClearAdvanced,
|
||||
activeDocRow,
|
||||
onDocOpenChange,
|
||||
completionDocs,
|
||||
onSaveCompDoc,
|
||||
onDateCompletedChange,
|
||||
activeMediaRow,
|
||||
onMediaOpenChange,
|
||||
}: WorkOrdersListOverlaysProps) {
|
||||
return (
|
||||
<>
|
||||
<WorkOrdersAdvancedSearchSheet
|
||||
open={advOpen}
|
||||
onOpenChange={onAdvOpenChange}
|
||||
initial={advInitial}
|
||||
onApply={onApplyAdvanced}
|
||||
onClearAll={onClearAdvanced}
|
||||
sites={sites}
|
||||
users={users}
|
||||
pmTypes={pmTypes}
|
||||
vendorTechs={vendorTechs}
|
||||
/>
|
||||
|
||||
<NewWoWizard open={wizardOpen} onOpenChange={onWizardOpenChange} users={users} />
|
||||
|
||||
{activeDocRow != null && (
|
||||
<CompDocDialog
|
||||
row={activeDocRow}
|
||||
open={!!activeDocRow}
|
||||
onOpenChange={onDocOpenChange}
|
||||
data={completionDocs[String(activeDocRow.id)]}
|
||||
// docStatus + completedDate persist via board PATCH (not legacy EditWorkorder).
|
||||
// Notes/generatedAt stay client-local (no content endpoint on this branch).
|
||||
onSave={onSaveCompDoc}
|
||||
onDateCompletedChange={onDateCompletedChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeMediaRow != null && (
|
||||
<MediaUploadModal
|
||||
row={activeMediaRow}
|
||||
open={!!activeMediaRow}
|
||||
onOpenChange={onMediaOpenChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
|
||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
type WorkOrdersListWizardProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
users: WorkOrderUser[];
|
||||
rows: WorkOrderTableRow[];
|
||||
onViewExisting: (row: WorkOrderTableRow) => void;
|
||||
};
|
||||
|
||||
export function WorkOrdersListWizard({
|
||||
open,
|
||||
onOpenChange,
|
||||
users,
|
||||
rows,
|
||||
onViewExisting,
|
||||
}: WorkOrdersListWizardProps) {
|
||||
return (
|
||||
<NewWoWizard
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
users={users}
|
||||
existingRows={rows}
|
||||
onViewExistingWo={(row) => {
|
||||
onOpenChange(false);
|
||||
onViewExisting(row);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import { Link as RouterLink } from "react-router";
|
||||
import { Breadcrumbs, Button, Chip, Link, Menu, MenuItem, Stack, Typography } from "@mui/material";
|
||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||
import {
|
||||
WORK_ORDER_STATUS_OPTIONS,
|
||||
type WorkOrderStatus,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
|
||||
const STATUS_COLORS: Record<
|
||||
string,
|
||||
"default" | "primary" | "secondary" | "error" | "info" | "success" | "warning"
|
||||
> = {
|
||||
Open: "primary",
|
||||
"In Progress": "info",
|
||||
"On Hold": "warning",
|
||||
Done: "success",
|
||||
Cancelled: "default",
|
||||
};
|
||||
|
||||
interface WorkOrderHeaderSectionProps {
|
||||
wo: WorkOrderDetail;
|
||||
statusAnchor: HTMLElement | null;
|
||||
onStatusAnchorChange: (anchor: HTMLElement | null) => void;
|
||||
onStatusChange: (status: WorkOrderStatus) => void;
|
||||
onDispatch: () => void;
|
||||
onEdit: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function WorkOrderHeaderSection({
|
||||
wo,
|
||||
statusAnchor,
|
||||
onStatusAnchorChange,
|
||||
onStatusChange,
|
||||
onDispatch,
|
||||
onEdit,
|
||||
onBack,
|
||||
}: WorkOrderHeaderSectionProps) {
|
||||
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) => onStatusAnchorChange(event.currentTarget)}
|
||||
className="w-fit cursor-pointer"
|
||||
/>
|
||||
<Menu
|
||||
anchorEl={statusAnchor}
|
||||
open={Boolean(statusAnchor)}
|
||||
onClose={() => onStatusAnchorChange(null)}
|
||||
>
|
||||
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
|
||||
<MenuItem
|
||||
key={status}
|
||||
selected={status === wo.status}
|
||||
onClick={() => {
|
||||
onStatusAnchorChange(null);
|
||||
if (status !== wo.status) {
|
||||
onStatusChange(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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,169 +1,139 @@
|
|||
import { Dialog } from "@mui/material";
|
||||
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||
|
||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
|
||||
|
||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||
|
||||
import { useLocationSites } from "@/domain/locations/use-cases/use-location-sites";
|
||||
|
||||
import { useWorkOrderVendors } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
|
||||
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
import { WizardFooter } from "@/app/(protected)/workorders/_components/wizard/wizard-footer";
|
||||
|
||||
import { WizardSidebar } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar";
|
||||
|
||||
import { WizardStepHeader } from "@/app/(protected)/workorders/_components/wizard/wizard-step-header";
|
||||
|
||||
import { WizardStepLocationService } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service";
|
||||
|
||||
import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
|
||||
|
||||
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
|
||||
|
||||
import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft";
|
||||
|
||||
import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill";
|
||||
import { useNewWoWizardController } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller";
|
||||
|
||||
type NewWoWizardProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
users: WorkOrderUser[];
|
||||
existingRows?: WorkOrderTableRow[];
|
||||
onViewExistingWo?: (row: WorkOrderTableRow) => void;
|
||||
};
|
||||
|
||||
export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) {
|
||||
const {
|
||||
step,
|
||||
setStep,
|
||||
draft,
|
||||
setDraft,
|
||||
setField,
|
||||
patchDraft: patchDraftOnly,
|
||||
svcOpen,
|
||||
setSvcOpen,
|
||||
svcSearch,
|
||||
setSvcSearch,
|
||||
} = useNewWoWizardDraft(open);
|
||||
|
||||
const { data: locations = [] } = useLocationSites();
|
||||
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
|
||||
const {
|
||||
data: locationDetail,
|
||||
isLoading: locationDetailLoading,
|
||||
isError: locationDetailError,
|
||||
} = useLocationDetail(draft.locationId || undefined);
|
||||
const { data: vendors = [] } = useWorkOrderVendors(
|
||||
draft.pm.trim() || undefined,
|
||||
locationDetail?.zipCode,
|
||||
);
|
||||
|
||||
const { notifyLocationChanged } = useWizardPocAutofill(
|
||||
open,
|
||||
draft.locationId,
|
||||
locationDetail,
|
||||
setDraft,
|
||||
);
|
||||
|
||||
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
|
||||
patchDraftOnly(patch);
|
||||
if (patch.locationId !== undefined) {
|
||||
notifyLocationChanged(patch.locationId);
|
||||
}
|
||||
};
|
||||
|
||||
const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false));
|
||||
const handleCreate = () => createMutation.mutate(draft);
|
||||
|
||||
const isCreating = createMutation.isPending;
|
||||
const handleClose = guardDialogClose(isCreating, () => onOpenChange(false));
|
||||
export function NewWoWizard({
|
||||
open,
|
||||
onOpenChange,
|
||||
users,
|
||||
existingRows = [],
|
||||
onViewExistingWo,
|
||||
}: NewWoWizardProps) {
|
||||
const wizard = useNewWoWizardController({ open, onOpenChange, existingRows });
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth={false}
|
||||
slotProps={{
|
||||
...dialogBlurBackdropSlotProps,
|
||||
paper: {
|
||||
className: "flex flex-col overflow-hidden p-0",
|
||||
style: {
|
||||
borderRadius: 12,
|
||||
width: 768,
|
||||
maxWidth: 768,
|
||||
height: 680,
|
||||
maxHeight: 680,
|
||||
pointerEvents: isCreating ? "none" : undefined,
|
||||
opacity: isCreating ? 0.85 : 1,
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={wizard.handleClose}
|
||||
maxWidth={false}
|
||||
slotProps={{
|
||||
...dialogBlurBackdropSlotProps,
|
||||
paper: {
|
||||
className: "flex flex-col overflow-hidden p-0",
|
||||
style: {
|
||||
borderRadius: 12,
|
||||
width: 768,
|
||||
maxWidth: 768,
|
||||
height: 680,
|
||||
maxHeight: 680,
|
||||
pointerEvents: wizard.isCreating ? "none" : undefined,
|
||||
opacity: wizard.isCreating ? 0.85 : 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Guided setup for creating a new work order</span>
|
||||
|
||||
<div className="flex min-h-0 flex-1" aria-busy={isCreating || undefined}>
|
||||
<WizardSidebar step={step} draft={draft} />
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
style={{ padding: 32, background: "var(--color-bg-surface)" }}
|
||||
>
|
||||
<WizardStepHeader step={step} />
|
||||
|
||||
{step === 1 && (
|
||||
<WizardStepTypeSchedule
|
||||
draft={draft}
|
||||
users={users}
|
||||
onUpdate={setField}
|
||||
onPatch={patchDraft}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<WizardStepLocationService
|
||||
draft={draft}
|
||||
locations={locations}
|
||||
locationDetail={locationDetail}
|
||||
locationDetailLoading={locationDetailLoading}
|
||||
locationDetailError={locationDetailError}
|
||||
services={problems.map((p) => ({ id: p.id, value: p.value }))}
|
||||
svcOpen={svcOpen}
|
||||
onSvcOpenChange={setSvcOpen}
|
||||
svcSearch={svcSearch}
|
||||
onSvcSearchChange={setSvcSearch}
|
||||
onUpdate={setField}
|
||||
onPatch={patchDraft}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<WizardStepVendorTime
|
||||
draft={draft}
|
||||
vendors={vendors}
|
||||
onUpdate={setField}
|
||||
onPatch={patchDraft}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WizardFooter
|
||||
step={step}
|
||||
draft={draft}
|
||||
onCancel={() => {
|
||||
if (!isCreating) onOpenChange(false);
|
||||
}}
|
||||
onBack={() => setStep((s) => s - 1)}
|
||||
onContinue={() => setStep((s) => s + 1)}
|
||||
onCreate={handleCreate}
|
||||
isCreating={isCreating}
|
||||
>
|
||||
<span className="sr-only">Guided setup for creating a new work order</span>
|
||||
|
||||
<div className="flex min-h-0 flex-1" aria-busy={wizard.isCreating || undefined}>
|
||||
<WizardSidebar step={wizard.step} draft={wizard.draft} />
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
style={{ padding: 32, background: "var(--color-bg-surface)" }}
|
||||
>
|
||||
<WizardStepHeader step={wizard.step} />
|
||||
|
||||
{wizard.step === 1 && (
|
||||
<WizardStepTypeSchedule
|
||||
draft={wizard.draft}
|
||||
users={users}
|
||||
showErrors={wizard.showErrors}
|
||||
onUpdate={wizard.setField}
|
||||
onPatch={wizard.patchDraft}
|
||||
onDuplicateWoNumber={wizard.handleDuplicateFound}
|
||||
/>
|
||||
)}
|
||||
|
||||
{wizard.step === 2 && (
|
||||
<WizardStepLocationService
|
||||
draft={wizard.draft}
|
||||
locations={wizard.locations}
|
||||
locationDetail={wizard.locationDetail}
|
||||
locationDetailLoading={wizard.locationDetailLoading}
|
||||
locationDetailError={wizard.locationDetailError}
|
||||
services={wizard.problems.map((p) => ({ id: p.id, value: p.value }))}
|
||||
showErrors={wizard.showErrors}
|
||||
svcOpen={wizard.svcOpen}
|
||||
onSvcOpenChange={wizard.setSvcOpen}
|
||||
svcSearch={wizard.svcSearch}
|
||||
onSvcSearchChange={wizard.setSvcSearch}
|
||||
onUpdate={wizard.setField}
|
||||
onPatch={wizard.patchDraft}
|
||||
/>
|
||||
)}
|
||||
|
||||
{wizard.step === 3 && (
|
||||
<WizardStepVendorTime
|
||||
draft={wizard.draft}
|
||||
vendors={wizard.vendors}
|
||||
onUpdate={wizard.setField}
|
||||
onPatch={wizard.patchDraft}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WizardFooter
|
||||
step={wizard.step}
|
||||
draft={wizard.draft}
|
||||
onCancel={() => {
|
||||
if (!wizard.isCreating) onOpenChange(false);
|
||||
}}
|
||||
onBack={() => {
|
||||
wizard.setShowErrors(false);
|
||||
wizard.setStep((s) => s - 1);
|
||||
}}
|
||||
onContinue={() => {
|
||||
wizard.setShowErrors(false);
|
||||
wizard.setStep((s) => s + 1);
|
||||
}}
|
||||
onAttemptContinue={() => wizard.setShowErrors(true)}
|
||||
onCreate={wizard.handleCreate}
|
||||
isCreating={wizard.isCreating}
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
<DuplicateWoNumberDialog
|
||||
open={wizard.duplicateRow != null}
|
||||
existing={wizard.duplicateRow}
|
||||
onDismiss={() => wizard.setDuplicateRow(null)}
|
||||
onViewExisting={(row) => {
|
||||
wizard.setDuplicateRow(null);
|
||||
onOpenChange(false);
|
||||
onViewExistingWo?.(row);
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import { useEffect } from "react";
|
||||
|
||||
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||
|
||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||
import { useLocationSites } from "@/domain/locations/use-cases/use-location-sites";
|
||||
|
||||
import { useWorkOrderVendors } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
|
||||
|
||||
import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft";
|
||||
import { useWizardDuplicateActions } from "@/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions";
|
||||
import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill";
|
||||
|
||||
type UseNewWoWizardControllerArgs = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
existingRows: WorkOrderTableRow[];
|
||||
};
|
||||
|
||||
/** Wires draft state, lookups, POC autofill, and duplicate-WO guards for the create wizard. */
|
||||
export function useNewWoWizardController({
|
||||
open,
|
||||
onOpenChange,
|
||||
existingRows,
|
||||
}: UseNewWoWizardControllerArgs) {
|
||||
const draftState = useNewWoWizardDraft(open);
|
||||
|
||||
const { data: locations = [] } = useLocationSites();
|
||||
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
|
||||
const {
|
||||
data: locationDetail,
|
||||
isLoading: locationDetailLoading,
|
||||
isError: locationDetailError,
|
||||
} = useLocationDetail(draftState.draft.locationId || undefined);
|
||||
const { data: vendors = [] } = useWorkOrderVendors(
|
||||
draftState.draft.pm.trim() || undefined,
|
||||
locationDetail?.zipCode,
|
||||
);
|
||||
|
||||
const { notifyLocationChanged } = useWizardPocAutofill(
|
||||
open,
|
||||
draftState.draft.locationId,
|
||||
locationDetail,
|
||||
draftState.setDraft,
|
||||
);
|
||||
|
||||
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
|
||||
draftState.patchDraft(patch);
|
||||
if (patch.locationId !== undefined) {
|
||||
notifyLocationChanged(patch.locationId);
|
||||
}
|
||||
};
|
||||
|
||||
const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false));
|
||||
const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } =
|
||||
useWizardDuplicateActions({
|
||||
open,
|
||||
draft: draftState.draft,
|
||||
existingRows,
|
||||
createMutation,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDuplicateRow(null);
|
||||
}
|
||||
}, [open, setDuplicateRow]);
|
||||
|
||||
const handleClose = guardDialogClose(isCreating, () => onOpenChange(false));
|
||||
|
||||
return {
|
||||
...draftState,
|
||||
patchDraft,
|
||||
locations,
|
||||
problems,
|
||||
locationDetail,
|
||||
locationDetailLoading,
|
||||
locationDetailError,
|
||||
vendors,
|
||||
duplicateRow,
|
||||
setDuplicateRow,
|
||||
handleDuplicateFound,
|
||||
handleCreate,
|
||||
isCreating,
|
||||
handleClose,
|
||||
};
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ export function useNewWoWizardDraft(open: boolean) {
|
|||
});
|
||||
const [svcOpen, setSvcOpen] = useState(false);
|
||||
const [svcSearch, setSvcSearch] = useState("");
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
|
|
@ -20,6 +21,7 @@ export function useNewWoWizardDraft(open: boolean) {
|
|||
setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
|
||||
setSvcOpen(false);
|
||||
setSvcSearch("");
|
||||
setShowErrors(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
|
|
@ -45,5 +47,7 @@ export function useNewWoWizardDraft(open: boolean) {
|
|||
setSvcOpen,
|
||||
svcSearch,
|
||||
setSvcSearch,
|
||||
showErrors,
|
||||
setShowErrors,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { isWoWizardMockEnabled } from "@/domain/work-orders/mocks/is-wo-wizard-mock-enabled";
|
||||
import {
|
||||
getMockWoWizardLocationDetail,
|
||||
MOCK_WO_WIZARD_LOCATIONS,
|
||||
MOCK_WO_WIZARD_SERVICES,
|
||||
MOCK_WO_WIZARD_USERS,
|
||||
MOCK_WO_WIZARD_VENDORS,
|
||||
} from "@/domain/work-orders/mocks/wo-wizard-mock-data";
|
||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
EMPTY_WIZARD_DRAFT,
|
||||
type WorkOrderWizardDraft,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||
import {
|
||||
useWorkOrderLocations,
|
||||
useWorkOrderVendors,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
|
||||
import { useWizardDuplicateActions } from "./use-wizard-duplicate-actions";
|
||||
|
||||
type UseNewWoWizardStateArgs = {
|
||||
open: boolean;
|
||||
users: WorkOrderUser[];
|
||||
existingRows: WorkOrderTableRow[];
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function useNewWoWizardState({
|
||||
open,
|
||||
users,
|
||||
existingRows,
|
||||
onOpenChange,
|
||||
}: UseNewWoWizardStateArgs) {
|
||||
const mockFallback = isWoWizardMockEnabled();
|
||||
const [step, setStep] = useState(1);
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
const [draft, setDraft] = useState<WorkOrderWizardDraft>({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
status: "Incomplete",
|
||||
});
|
||||
const [pocAutoFilledFor, setPocAutoFilledFor] = useState("");
|
||||
|
||||
const { data: apiLocations = [] } = useWorkOrderLocations();
|
||||
const { data: apiProblems = [] } = useDropdownOptionsByCategory("Problem");
|
||||
const isMockLocation = draft.locationId.startsWith("mock-");
|
||||
const { data: apiLocationDetail } = useLocationDetail(
|
||||
isMockLocation ? undefined : draft.locationId || undefined,
|
||||
);
|
||||
const mockLocationDetail = isMockLocation
|
||||
? getMockWoWizardLocationDetail(draft.locationId)
|
||||
: undefined;
|
||||
const locationDetail = mockLocationDetail ?? apiLocationDetail;
|
||||
const { data: apiVendors = [] } = useWorkOrderVendors(
|
||||
undefined,
|
||||
isMockLocation ? undefined : locationDetail?.zipCode,
|
||||
);
|
||||
const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false));
|
||||
const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } =
|
||||
useWizardDuplicateActions({
|
||||
open,
|
||||
draft,
|
||||
existingRows,
|
||||
createMutation,
|
||||
});
|
||||
|
||||
const wizardUsers = useMemo(() => {
|
||||
if (users.length > 0) {
|
||||
return users;
|
||||
}
|
||||
return mockFallback ? MOCK_WO_WIZARD_USERS : [];
|
||||
}, [mockFallback, users]);
|
||||
const locations =
|
||||
apiLocations.length > 0 ? apiLocations : mockFallback ? MOCK_WO_WIZARD_LOCATIONS : [];
|
||||
const problems =
|
||||
apiProblems.length > 0 ? apiProblems : mockFallback ? MOCK_WO_WIZARD_SERVICES : [];
|
||||
const vendors = apiVendors.length > 0 ? apiVendors : mockFallback ? MOCK_WO_WIZARD_VENDORS : [];
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep(1);
|
||||
setShowErrors(false);
|
||||
setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
|
||||
setPocAutoFilledFor("");
|
||||
setDuplicateRow(null);
|
||||
}
|
||||
}, [open, setDuplicateRow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft.locationId || !locationDetail) {
|
||||
return;
|
||||
}
|
||||
if (pocAutoFilledFor === draft.locationId) {
|
||||
return;
|
||||
}
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
pocName: locationDetail.contact ?? current.pocName,
|
||||
pocPhone: locationDetail.phone ?? current.pocPhone,
|
||||
}));
|
||||
setPocAutoFilledFor(draft.locationId);
|
||||
}, [draft.locationId, locationDetail, pocAutoFilledFor]);
|
||||
|
||||
const setField = <K extends keyof WorkOrderWizardDraft>(
|
||||
key: K,
|
||||
value: WorkOrderWizardDraft[K],
|
||||
) => {
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
};
|
||||
|
||||
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
|
||||
setDraft((current) => ({ ...current, ...patch }));
|
||||
if (patch.locationId !== undefined && patch.locationId !== pocAutoFilledFor) {
|
||||
setPocAutoFilledFor("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
step,
|
||||
setStep,
|
||||
showErrors,
|
||||
setShowErrors,
|
||||
draft,
|
||||
duplicateRow,
|
||||
setDuplicateRow,
|
||||
wizardUsers,
|
||||
locations,
|
||||
problems,
|
||||
vendors,
|
||||
locationDetail,
|
||||
setField,
|
||||
patchDraft,
|
||||
handleDuplicateFound,
|
||||
handleCreate,
|
||||
isCreating,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
|
||||
type UseWizardDuplicateActionsArgs = {
|
||||
open: boolean;
|
||||
draft: WorkOrderWizardDraft;
|
||||
existingRows: WorkOrderTableRow[];
|
||||
createMutation: {
|
||||
mutate: (draft: WorkOrderWizardDraft) => void;
|
||||
isPending: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export function useWizardDuplicateActions({
|
||||
open,
|
||||
draft,
|
||||
existingRows,
|
||||
createMutation,
|
||||
}: UseWizardDuplicateActionsArgs) {
|
||||
const [duplicateRow, setDuplicateRow] = useState<WorkOrderTableRow | null>(null);
|
||||
const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false);
|
||||
const openRef = useRef(open);
|
||||
const draftRef = useRef(draft);
|
||||
|
||||
useEffect(() => {
|
||||
openRef.current = open;
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
draftRef.current = draft;
|
||||
}, [draft]);
|
||||
|
||||
const resolveDuplicate = async (woNumber: string) => {
|
||||
const localDuplicate = findDuplicateWoNumberRow(woNumber, existingRows);
|
||||
if (localDuplicate) {
|
||||
return localDuplicate;
|
||||
}
|
||||
|
||||
// The loaded board rows are only an immediate UX hint; the unfiltered API lookup is authoritative.
|
||||
return workOrdersApi.findByWoNumber(woNumber);
|
||||
};
|
||||
|
||||
const handleDuplicateFound = (woNumber: string) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const duplicate = await resolveDuplicate(woNumber);
|
||||
// Ignore stale lookups after dismiss or after the user edits the WO#.
|
||||
if (!openRef.current || woNumber !== draftRef.current.woNumber) {
|
||||
return;
|
||||
}
|
||||
if (duplicate) {
|
||||
setDuplicateRow(duplicate);
|
||||
}
|
||||
} catch {
|
||||
if (!openRef.current) {
|
||||
return;
|
||||
}
|
||||
toast.error("Unable to verify the work order number. Please try again.");
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
const woNumberAtSubmit = draft.woNumber;
|
||||
void (async () => {
|
||||
setIsCheckingDuplicate(true);
|
||||
try {
|
||||
const duplicate = await resolveDuplicate(woNumberAtSubmit);
|
||||
// Ignore after dismiss or if the WO# changed while the request was in flight.
|
||||
if (!openRef.current || woNumberAtSubmit !== draftRef.current.woNumber) {
|
||||
return;
|
||||
}
|
||||
if (duplicate) {
|
||||
setDuplicateRow(duplicate);
|
||||
return;
|
||||
}
|
||||
createMutation.mutate(draftRef.current);
|
||||
} catch {
|
||||
if (!openRef.current) {
|
||||
return;
|
||||
}
|
||||
toast.error("Unable to verify the work order number. Please try again.");
|
||||
} finally {
|
||||
setIsCheckingDuplicate(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
return {
|
||||
duplicateRow,
|
||||
setDuplicateRow,
|
||||
handleDuplicateFound,
|
||||
handleCreate,
|
||||
isCreating: createMutation.isPending || isCheckingDuplicate,
|
||||
};
|
||||
}
|
||||
|
|
@ -3,8 +3,10 @@ import { Button } from "@/components/ui/button";
|
|||
import {
|
||||
WIZARD_STEPS,
|
||||
canContinueWizardStep1,
|
||||
canContinueWizardStep2,
|
||||
canCreateWorkOrderFromWizard,
|
||||
isWizardStepValid,
|
||||
isWizardWoNumberValid,
|
||||
type WorkOrderWizardDraft,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
|
|
@ -15,9 +17,24 @@ type WizardFooterProps = {
|
|||
onBack: () => void;
|
||||
onContinue: () => void;
|
||||
onCreate: () => void;
|
||||
onAttemptContinue?: () => void;
|
||||
isCreating: boolean;
|
||||
};
|
||||
|
||||
function stepHint(step: number, draft: WorkOrderWizardDraft): string | null {
|
||||
if (step === 1 && !canContinueWizardStep1(draft)) {
|
||||
if (!isWizardWoNumberValid(draft.woNumber)) {
|
||||
return "Enter an alphanumeric WO number, or leave blank for SH generation";
|
||||
}
|
||||
return "Select severity to continue";
|
||||
}
|
||||
if (step === 2 && !canContinueWizardStep2(draft)) {
|
||||
if (!draft.locationId) return "Pick a site to continue";
|
||||
return "Enter POC name and phone to continue";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function WizardFooter({
|
||||
step,
|
||||
draft,
|
||||
|
|
@ -25,11 +42,12 @@ export function WizardFooter({
|
|||
onBack,
|
||||
onContinue,
|
||||
onCreate,
|
||||
onAttemptContinue,
|
||||
isCreating,
|
||||
}: WizardFooterProps) {
|
||||
const stepValid = isWizardStepValid(step, draft);
|
||||
const canCreate = canCreateWorkOrderFromWizard(draft);
|
||||
const canContinue1 = canContinueWizardStep1(draft);
|
||||
const hint = stepHint(step, draft);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -60,20 +78,25 @@ export function WizardFooter({
|
|||
)}
|
||||
|
||||
<div className="flex flex-1 justify-center">
|
||||
{step === 1 && !canContinue1 && (
|
||||
{Boolean(hint) && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
|
||||
>
|
||||
<Lightbulb size={13} /> Pick a type to continue
|
||||
<Lightbulb size={13} /> {hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step < WIZARD_STEPS.length ? (
|
||||
<Button
|
||||
disabled={!stepValid}
|
||||
onClick={onContinue}
|
||||
onClick={() => {
|
||||
if (!stepValid) {
|
||||
onAttemptContinue?.();
|
||||
return;
|
||||
}
|
||||
onContinue();
|
||||
}}
|
||||
endIcon={<ChevronRight size={14} />}
|
||||
sx={{
|
||||
background: "var(--color-primary)",
|
||||
|
|
@ -81,17 +104,23 @@ export function WizardFooter({
|
|||
height: 36,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
...(!stepValid ? { opacity: 0.5, pointerEvents: "none" } : {}),
|
||||
...(!stepValid ? { opacity: 0.7 } : {}),
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled={!canCreate || isCreating}
|
||||
disabled={isCreating}
|
||||
loading={isCreating}
|
||||
loadingText="Creating…"
|
||||
onClick={onCreate}
|
||||
onClick={() => {
|
||||
if (!canCreate) {
|
||||
onAttemptContinue?.();
|
||||
return;
|
||||
}
|
||||
onCreate();
|
||||
}}
|
||||
startIcon={!isCreating ? <Check size={14} /> : undefined}
|
||||
sx={{
|
||||
background: "var(--color-primary)",
|
||||
|
|
@ -99,7 +128,7 @@ export function WizardFooter({
|
|||
height: 36,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
...(!canCreate ? { opacity: 0.5, pointerEvents: "none" } : {}),
|
||||
...(!canCreate ? { opacity: 0.7 } : {}),
|
||||
}}
|
||||
>
|
||||
Create work order
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { Text } from "@/components/ui/text";
|
||||
import type { WorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
|
||||
import { SeverityOptionsList } from "@/app/(protected)/workorders/_components/list/table/cells/severity-options-list";
|
||||
import { WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||
|
||||
type WizardSeverityPickerProps = {
|
||||
value: WorkOrderSeverity | null;
|
||||
onSelect: (severity: WorkOrderSeverity) => void;
|
||||
showError: boolean;
|
||||
};
|
||||
|
||||
export function WizardSeverityPicker({ value, onSelect, showError }: WizardSeverityPickerProps) {
|
||||
return (
|
||||
<div>
|
||||
<WizLabel required>Severity (from APM)</WizLabel>
|
||||
<Text
|
||||
as="p"
|
||||
variant="description"
|
||||
sx={{ fontSize: 11, color: "var(--muted-foreground)", margin: "0 0 8px" }}
|
||||
>
|
||||
Transcribe the severity already assigned on the client's ticket.
|
||||
</Text>
|
||||
<SeverityOptionsList value={value} onSelect={onSelect} />
|
||||
{Boolean(showError && value == null) && (
|
||||
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
|
||||
Severity is required for Emergency and Reactive work orders.
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,17 +4,24 @@ import {
|
|||
DLG_INPUT_CLS,
|
||||
DLG_INPUT_STYLE,
|
||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||
import { WizSubLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||
import { WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||
|
||||
type WizardStepLocationPocFieldsProps = {
|
||||
draft: WorkOrderWizardDraft;
|
||||
showErrors?: boolean;
|
||||
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
|
||||
};
|
||||
|
||||
export function WizardStepLocationPocFields({ draft, onUpdate }: WizardStepLocationPocFieldsProps) {
|
||||
export function WizardStepLocationPocFields({
|
||||
draft,
|
||||
showErrors = false,
|
||||
onUpdate,
|
||||
}: WizardStepLocationPocFieldsProps) {
|
||||
const pocIncomplete = !draft.pocName.trim() || !draft.pocPhone.trim();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<WizSubLabel>Point of contact</WizSubLabel>
|
||||
<WizLabel required>Point of contact</WizLabel>
|
||||
<Text
|
||||
as="p"
|
||||
variant="description"
|
||||
|
|
@ -38,6 +45,11 @@ export function WizardStepLocationPocFields({ draft, onUpdate }: WizardStepLocat
|
|||
style={DLG_INPUT_STYLE}
|
||||
/>
|
||||
</div>
|
||||
{Boolean(showErrors && pocIncomplete) && (
|
||||
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
|
||||
POC name and phone are required.
|
||||
</Text>
|
||||
)}
|
||||
<textarea
|
||||
value={draft.pocNotes}
|
||||
onChange={(e) => onUpdate("pocNotes", e.target.value)}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ type WizardStepLocationServiceProps = {
|
|||
locationDetailLoading?: boolean;
|
||||
locationDetailError?: boolean;
|
||||
services: ServiceOption[];
|
||||
showErrors?: boolean;
|
||||
svcOpen: boolean;
|
||||
onSvcOpenChange: (open: boolean) => void;
|
||||
svcSearch: string;
|
||||
|
|
@ -31,6 +32,7 @@ export function WizardStepLocationService({
|
|||
locationDetailLoading,
|
||||
locationDetailError,
|
||||
services,
|
||||
showErrors = false,
|
||||
svcOpen,
|
||||
onSvcOpenChange,
|
||||
svcSearch,
|
||||
|
|
@ -57,7 +59,7 @@ export function WizardStepLocationService({
|
|||
locationDetailLoading={locationDetailLoading}
|
||||
locationDetailError={locationDetailError}
|
||||
/>
|
||||
<WizardStepLocationPocFields draft={draft} onUpdate={onUpdate} />
|
||||
<WizardStepLocationPocFields draft={draft} showErrors={showErrors} onUpdate={onUpdate} />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { WO_TYPE_META, WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import {
|
||||
WO_TYPE_META,
|
||||
WO_TYPES,
|
||||
isWizardWoNumberValid,
|
||||
type WOType,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import {
|
||||
typeNeedsSeverity,
|
||||
type WorkOrderSeverity,
|
||||
} from "@/domain/work-orders/types/work-order-severity";
|
||||
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
|
||||
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
|
||||
|
|
@ -15,6 +24,7 @@ import {
|
|||
WizLabel,
|
||||
WizLabelRow,
|
||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||
import { WizardSeverityPicker } from "@/app/(protected)/workorders/_components/wizard/wizard-severity-picker";
|
||||
import { CheckCircle2, Clock, Flame, Plus, Users, Wrench, Zap } from "lucide-react";
|
||||
import type { ElementType } from "react";
|
||||
|
||||
|
|
@ -26,6 +36,17 @@ const WO_TYPE_ICONS: Record<WOType, ElementType> = {
|
|||
Overdue: Clock,
|
||||
};
|
||||
|
||||
function nextSeverityForType(
|
||||
currentType: WOType | "",
|
||||
nextType: WOType,
|
||||
currentSeverity: WorkOrderSeverity | null,
|
||||
): WorkOrderSeverity | null {
|
||||
if (!typeNeedsSeverity(nextType) || currentType !== nextType) {
|
||||
return null;
|
||||
}
|
||||
return currentSeverity;
|
||||
}
|
||||
|
||||
function WoTypePicker({
|
||||
selected,
|
||||
onSelect,
|
||||
|
|
@ -113,40 +134,151 @@ function WoTypePicker({
|
|||
);
|
||||
}
|
||||
|
||||
type WizardTypeScheduleDatesProps = {
|
||||
draft: WorkOrderWizardDraft;
|
||||
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
|
||||
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
|
||||
};
|
||||
|
||||
function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeScheduleDatesProps) {
|
||||
return (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||
<div>
|
||||
<WizLabelRow
|
||||
optional
|
||||
onClear={
|
||||
draft.scheduledOn !== "" || draft.scheduleWeekOnly || draft.scheduleEnd
|
||||
? () =>
|
||||
onPatch({
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
targetWeek: "",
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Schedule On
|
||||
</WizLabelRow>
|
||||
<WizardDateField
|
||||
value={draft.scheduleWeekOnly ? "" : draft.scheduledOn}
|
||||
targetWeek={draft.scheduleWeekOnly ? draft.targetWeek || draft.scheduledOn : ""}
|
||||
placeholder="Pick a date"
|
||||
allowWeekMode
|
||||
scheduleEnd={draft.scheduleEnd}
|
||||
schedMode={draft.schedMode}
|
||||
onChange={(iso) =>
|
||||
onPatch({
|
||||
scheduledOn: iso,
|
||||
scheduleWeekOnly: false,
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
targetWeek: "",
|
||||
})
|
||||
}
|
||||
onChangeWeek={(w) =>
|
||||
onPatch({
|
||||
scheduledOn: w,
|
||||
scheduleWeekOnly: Boolean(w),
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
targetWeek: w,
|
||||
})
|
||||
}
|
||||
onChangeRange={(s, e) =>
|
||||
onPatch({
|
||||
scheduledOn: s,
|
||||
scheduleEnd: e,
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<WizLabelRow
|
||||
optional
|
||||
onClear={draft.dueDate !== "" ? () => onUpdate("dueDate", "") : undefined}
|
||||
>
|
||||
Due Date
|
||||
</WizLabelRow>
|
||||
<WizardDueDateField
|
||||
type={draft.type}
|
||||
value={draft.dueDate}
|
||||
placeholder="No due date"
|
||||
onChange={(iso) => onUpdate("dueDate", iso)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type WizardStepTypeScheduleProps = {
|
||||
draft: WorkOrderWizardDraft;
|
||||
users: WorkOrderUser[];
|
||||
showErrors?: boolean;
|
||||
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
|
||||
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
|
||||
onDuplicateWoNumber?: (woNumber: string) => void;
|
||||
};
|
||||
|
||||
export function WizardStepTypeSchedule({
|
||||
draft,
|
||||
users,
|
||||
showErrors = false,
|
||||
onUpdate,
|
||||
onPatch,
|
||||
onDuplicateWoNumber,
|
||||
}: WizardStepTypeScheduleProps) {
|
||||
const needsSeverity = typeNeedsSeverity(draft.type);
|
||||
const woNumberError = showErrors && !isWizardWoNumberValid(draft.woNumber);
|
||||
const selectedUser = users.find((u) => String(u.id) === draft.assigneeId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<WizLabel required>Type of WO</WizLabel>
|
||||
<WoTypePicker selected={draft.type} onSelect={(t) => onUpdate("type", t)} />
|
||||
<WizLabel optional>Type of WO</WizLabel>
|
||||
<WoTypePicker
|
||||
selected={draft.type}
|
||||
onSelect={(t) => {
|
||||
onPatch({
|
||||
type: t,
|
||||
severity: nextSeverityForType(draft.type, t, draft.severity),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{Boolean(needsSeverity) && (
|
||||
<WizardSeverityPicker
|
||||
value={draft.severity}
|
||||
showError={showErrors}
|
||||
onSelect={(severity) => onUpdate("severity", severity)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<WizLabel optional>WO Number</WizLabel>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={draft.woNumber}
|
||||
onChange={(e) => onUpdate("woNumber", e.target.value.replace(/\D/g, "").slice(0, 20))}
|
||||
placeholder="e.g. 12345678901"
|
||||
onChange={(e) =>
|
||||
onUpdate("woNumber", e.target.value.replace(/[^a-zA-Z0-9]/g, "").slice(0, 20))
|
||||
}
|
||||
onBlur={() => onDuplicateWoNumber?.(draft.woNumber)}
|
||||
placeholder="Leave blank for SH provisional number"
|
||||
maxLength={20}
|
||||
className={DLG_INPUT_CLS}
|
||||
style={DLG_INPUT_STYLE}
|
||||
/>
|
||||
{Boolean(woNumberError) && (
|
||||
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
|
||||
WO number must be alphanumeric.
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
@ -177,77 +309,7 @@ export function WizardStepTypeSchedule({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||
<div>
|
||||
<WizLabelRow
|
||||
optional
|
||||
onClear={
|
||||
draft.scheduledOn !== "" || draft.scheduleWeekOnly || draft.scheduleEnd
|
||||
? () =>
|
||||
onPatch({
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
targetWeek: "",
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Schedule On
|
||||
</WizLabelRow>
|
||||
<WizardDateField
|
||||
value={draft.scheduleWeekOnly ? "" : draft.scheduledOn}
|
||||
targetWeek={draft.scheduleWeekOnly ? draft.targetWeek || draft.scheduledOn : ""}
|
||||
placeholder="Pick a date"
|
||||
allowWeekMode
|
||||
scheduleEnd={draft.scheduleEnd}
|
||||
schedMode={draft.schedMode}
|
||||
onChange={(iso) =>
|
||||
onPatch({
|
||||
scheduledOn: iso,
|
||||
scheduleWeekOnly: false,
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
targetWeek: "",
|
||||
})
|
||||
}
|
||||
onChangeWeek={(w) =>
|
||||
onPatch({
|
||||
scheduledOn: w,
|
||||
scheduleWeekOnly: Boolean(w),
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
targetWeek: w,
|
||||
})
|
||||
}
|
||||
onChangeRange={(s, e) =>
|
||||
onPatch({
|
||||
scheduledOn: s,
|
||||
scheduleEnd: e,
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<WizLabelRow
|
||||
optional
|
||||
onClear={draft.dueDate !== "" ? () => onUpdate("dueDate", "") : undefined}
|
||||
>
|
||||
Due Date
|
||||
</WizLabelRow>
|
||||
<WizardDueDateField
|
||||
type={draft.type}
|
||||
value={draft.dueDate}
|
||||
placeholder="No due date"
|
||||
onChange={(iso) => onUpdate("dueDate", iso)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<WizardTypeScheduleDates draft={draft} onUpdate={onUpdate} onPatch={onPatch} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
|
||||
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
|
|
@ -74,7 +74,7 @@ function WizardVendorDetail({
|
|||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
|
||||
{vendor.companyName || "ÔÇö"}
|
||||
{vendor.companyName || "—"}
|
||||
</div>
|
||||
{Boolean(distance) && (
|
||||
<span className="shrink-0 text-[11px]" style={{ color: "var(--muted-foreground)" }}>
|
||||
|
|
@ -155,7 +155,7 @@ export function WizardStepVendorTime({
|
|||
</WizLabelRow>
|
||||
<WizardFieldSelect
|
||||
value={draft.vendorId}
|
||||
placeholder="Select technician"
|
||||
placeholder="Select technician…"
|
||||
icon={
|
||||
selectedVendor ? (
|
||||
<span
|
||||
|
|
@ -178,7 +178,7 @@ export function WizardStepVendorTime({
|
|||
<Wrench size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||
)
|
||||
}
|
||||
searchPlaceholder="Search technician"
|
||||
searchPlaceholder="Search technician…"
|
||||
options={vendors.map((v) => ({
|
||||
key: String(v.id),
|
||||
label: formatVendorTechnicianLabel(v),
|
||||
|
|
@ -258,7 +258,7 @@ export function WizardStepVendorTime({
|
|||
<WizLabel>Status</WizLabel>
|
||||
<WizardFieldSelect
|
||||
value={statusValue}
|
||||
placeholder="Select status"
|
||||
placeholder="Select status…"
|
||||
search={false}
|
||||
icon={<ColorDot color={statusDotColor} />}
|
||||
options={buildWizardStatusOptions(closabilityGaps)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useAddWorkOrderComment,
|
||||
useChangeWorkOrderAssignment,
|
||||
useChangeWorkOrderStatus,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import { useVendorEditor } from "@/app/(protected)/workorders/_hooks/use-vendor-editor";
|
||||
|
||||
export type WorkOrderActivityTab = "customer" | "internal" | "audit";
|
||||
|
||||
export function useWorkOrderViewController(workOrderId: string) {
|
||||
const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
|
||||
const { data: users = [] } = useWorkOrderUsers();
|
||||
const statusMutation = useChangeWorkOrderStatus(workOrderId);
|
||||
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
|
||||
const commentMutation = useAddWorkOrderComment(workOrderId);
|
||||
const vendorEditor = useVendorEditor(workOrderId, () => void refetch());
|
||||
|
||||
const [activeTab, setActiveTab] = useState<WorkOrderActivityTab>("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 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("") },
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
wo,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
users,
|
||||
statusMutation,
|
||||
assignmentMutation,
|
||||
commentMutation,
|
||||
vendorEditor,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
commentText,
|
||||
setCommentText,
|
||||
dispatchOpen,
|
||||
setDispatchOpen,
|
||||
dispatchDetailId,
|
||||
setDispatchDetailId,
|
||||
statusAnchor,
|
||||
setStatusAnchor,
|
||||
assignAnchor,
|
||||
setAssignAnchor,
|
||||
customerComments,
|
||||
internalComments,
|
||||
auditLog,
|
||||
handleCommentSubmit,
|
||||
};
|
||||
}
|
||||
|
||||
export type WorkOrderViewController = ReturnType<typeof useWorkOrderViewController>;
|
||||
|
|
@ -49,12 +49,16 @@ export function useWorkOrdersTableUiState(weekMonday: string) {
|
|||
const startDayDraft = useCallback(
|
||||
(key: string, scheduledIso?: string) => {
|
||||
setDraftDayKey(key);
|
||||
// Unscheduled "+ Add WO" starts with no schedule (SH-109); day groups prefill the day.
|
||||
if (key === UNSCHEDULED_DAY_KEY) {
|
||||
setDraft({ ...EMPTY_TABLE_DRAFT });
|
||||
return;
|
||||
}
|
||||
const scheduledOn = resolveDraftScheduledOn(key, weekMonday, scheduledIso);
|
||||
setDraft({
|
||||
...EMPTY_TABLE_DRAFT,
|
||||
scheduledOn,
|
||||
scheduleWeekOnly: key === UNSCHEDULED_DAY_KEY,
|
||||
targetWeek: key === UNSCHEDULED_DAY_KEY ? weekMonday : "",
|
||||
status: scheduledOn ? "Scheduled" : "Incomplete",
|
||||
});
|
||||
},
|
||||
[weekMonday],
|
||||
|
|
|
|||
|
|
@ -1,28 +1,34 @@
|
|||
import { Box } from "@mui/material";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
||||
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
|
||||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
|
||||
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
|
||||
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
|
||||
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
|
||||
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
|
||||
import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays";
|
||||
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
|
||||
import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard";
|
||||
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
|
||||
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
||||
function openCompletionDoc(
|
||||
row: WorkOrderTableRow,
|
||||
setDocRow: (row: WorkOrderTableRow | null) => void,
|
||||
) {
|
||||
if (!hasCompletionDocService(row.pm)) return;
|
||||
setDocRow(row);
|
||||
}
|
||||
|
||||
export default function WorkOrdersListPage() {
|
||||
const { user } = useAuthContext();
|
||||
const page = useWorkOrdersListPage();
|
||||
const { filters, tableData, activeDocRow } = page;
|
||||
|
||||
const openCompletionDoc = (row: WorkOrderTableRow) => {
|
||||
if (!hasCompletionDocService(row.pm)) return;
|
||||
page.setDocRow(row);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
||||
<PageHeader
|
||||
|
|
@ -88,42 +94,31 @@ export default function WorkOrdersListPage() {
|
|||
weekMonday={filters.weekMonday}
|
||||
onWeekChange={filters.setWeekMonday}
|
||||
onOpenSlideOver={page.handleOpenSlideOver}
|
||||
onOpenCompDoc={openCompletionDoc}
|
||||
onOpenCompDoc={(row) => openCompletionDoc(row, page.setDocRow)}
|
||||
onOpenMedia={page.setMediaRow}
|
||||
onClearSearch={() => filters.handleSearchChange("")}
|
||||
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||
onPatch={page.patchField}
|
||||
/>
|
||||
|
||||
<WorkOrdersListOverlays
|
||||
wizardOpen={page.wizardOpen}
|
||||
onWizardOpenChange={page.setWizardOpen}
|
||||
<WorkOrdersListWizard
|
||||
open={page.wizardOpen}
|
||||
onOpenChange={page.setWizardOpen}
|
||||
users={page.users}
|
||||
rows={tableData.rows}
|
||||
onViewExisting={(row) => page.handleOpenSlideOver(row, false)}
|
||||
/>
|
||||
|
||||
<WorkOrdersAdvancedSearchSheet
|
||||
open={filters.advOpen}
|
||||
onOpenChange={filters.setAdvOpen}
|
||||
initial={filters.advInitial}
|
||||
onApply={filters.applyAdvancedFilters}
|
||||
onClearAll={filters.clearAdvancedFilters}
|
||||
sites={page.locations}
|
||||
users={page.users}
|
||||
pmTypes={page.pmTypes}
|
||||
vendorTechs={page.vendorTechs}
|
||||
advOpen={filters.advOpen}
|
||||
onAdvOpenChange={filters.setAdvOpen}
|
||||
advInitial={filters.advInitial}
|
||||
onApplyAdvanced={filters.applyAdvancedFilters}
|
||||
onClearAdvanced={filters.clearAdvancedFilters}
|
||||
activeDocRow={activeDocRow}
|
||||
onDocOpenChange={(open) => {
|
||||
if (!open) page.setDocRow(null);
|
||||
}}
|
||||
completionDocs={page.completionDocs}
|
||||
onSaveCompDoc={async (data, status) => {
|
||||
if (!activeDocRow) return;
|
||||
await page.saveCompletionDoc(activeDocRow, data, status);
|
||||
}}
|
||||
onDateCompletedChange={async (date) => {
|
||||
if (!activeDocRow) return;
|
||||
await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
|
||||
}}
|
||||
activeMediaRow={page.activeMediaRow}
|
||||
onMediaOpenChange={(open) => {
|
||||
if (!open) page.setMediaRow(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<WorkOrderSlideOver
|
||||
|
|
@ -134,8 +129,8 @@ export default function WorkOrdersListPage() {
|
|||
vendors={page.vendors}
|
||||
pmTypes={page.pmTypes}
|
||||
allOrders={tableData.rows}
|
||||
onSave={page.patchField}
|
||||
onOpenCompletionDoc={openCompletionDoc}
|
||||
onSave={(id, patch, options) => page.patchField(id, patch, options)}
|
||||
onOpenCompletionDoc={(row) => openCompletionDoc(row, page.setDocRow)}
|
||||
onCancelWO={page.setConfirmCancel}
|
||||
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
|
||||
saving={page.isPatchPending}
|
||||
|
|
@ -149,6 +144,31 @@ export default function WorkOrdersListPage() {
|
|||
onKeepWO={() => page.setConfirmCancel(null)}
|
||||
onConfirm={page.handleConfirmCancel}
|
||||
/>
|
||||
|
||||
{page.activeMediaRow != null && (
|
||||
<MediaUploadModal
|
||||
row={page.activeMediaRow}
|
||||
open={Boolean(page.activeMediaRow)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) page.setMediaRow(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeDocRow != null && (
|
||||
<CompDocDialog
|
||||
row={activeDocRow}
|
||||
open={Boolean(activeDocRow)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) page.setDocRow(null);
|
||||
}}
|
||||
data={page.completionDocs[String(activeDocRow.id)]}
|
||||
onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
|
||||
onDateCompletedChange={async (date) => {
|
||||
await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,10 +50,17 @@ export function PageHeader({
|
|||
"m-0 font-display mt-1! text-[30px]! font-extrabold leading-[1.1] tracking-[-0.02em] text-foreground",
|
||||
eyebrow && "mt-1",
|
||||
)}
|
||||
sx={{ fontSize: 30, fontWeight: 800, lineHeight: 1.1, letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<Text variant="description" tone="muted" when={Boolean(subtitle)}>
|
||||
<Text
|
||||
variant="description"
|
||||
as="span"
|
||||
tone="muted"
|
||||
when={Boolean(subtitle)}
|
||||
sx={{ mt: 0.75, fontWeight: 500 }}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -91,11 +91,6 @@ export function useSaveVendorCompanyRoster(): UseMutationResult<
|
|||
? getSingleStatusOnlyChange(originalRoster, values)
|
||||
: null;
|
||||
if (statusChange) {
|
||||
if (statusChange.isActive === false) {
|
||||
throw new Error(
|
||||
"Use Deactivate to check open work orders before inactivating a technician.",
|
||||
);
|
||||
}
|
||||
const baseRoster = originalRoster as VendorCompanyRoster;
|
||||
await vendorsApi.update(statusChange.id, { isActive: statusChange.isActive });
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -191,6 +191,48 @@ export const workOrdersApi = {
|
|||
};
|
||||
},
|
||||
|
||||
findByWoNumber: async (woNumber: string): Promise<WorkOrderTableRow | undefined> => {
|
||||
const normalized = woNumber.trim();
|
||||
// Search every non-empty WO# — backend advanced search accepts 1-character queries, and
|
||||
// wizard/create allow single-digit numbers (SH-189 must not bypass remote uniqueness).
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
// Safety cap — if hit before exhausting results, fail closed (do not treat as unique).
|
||||
const safetyMaxPages = 50;
|
||||
|
||||
// `board/search` is fuzzy and paginated (0-based); exhaust pages for an exact WO#.
|
||||
const pageSize = 100;
|
||||
let page = 0;
|
||||
let scanned = 0;
|
||||
let totalCount = Number.POSITIVE_INFINITY;
|
||||
|
||||
while (scanned < totalCount) {
|
||||
if (page >= safetyMaxPages) {
|
||||
throw new Error(
|
||||
"Unable to verify the work order number: search result set exceeded the safety page limit.",
|
||||
);
|
||||
}
|
||||
const result = await workOrdersApi.searchBoard({
|
||||
search: normalized,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
const exact = result.items.find((row) => row.woNumber === normalized);
|
||||
if (exact) {
|
||||
return exact;
|
||||
}
|
||||
scanned += result.items.length;
|
||||
totalCount = result.totalCount;
|
||||
if (result.items.length === 0) {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
|
||||
getListByLocation: async (
|
||||
locationId: string | number,
|
||||
search = "",
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ export function assignWizardScheduleFields(
|
|||
const isRange = draft.schedMode === "range";
|
||||
|
||||
if (!isRange && draft.scheduleWeekOnly) {
|
||||
payload.scheduleWeekOnly = true;
|
||||
|
||||
const week = draft.targetWeek?.trim() || (draft.scheduledOn ? mondayOf(draft.scheduledOn) : "");
|
||||
|
||||
// Only emit week-mode flags when a concrete week exists (SH-109: undated create).
|
||||
if (week) {
|
||||
payload.scheduleWeekOnly = true;
|
||||
payload.targetWeek = week;
|
||||
}
|
||||
} else if (draft.scheduledOn && (isRange || !draft.scheduleWeekOnly)) {
|
||||
|
|
|
|||
|
|
@ -53,4 +53,8 @@ export function assignWizardServicePocFields(
|
|||
if (vendorNotes) {
|
||||
payload.vendorNotes = vendorNotes;
|
||||
}
|
||||
|
||||
if (draft.severity != null) {
|
||||
payload.severity = draft.severity;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ type ScalarBoardOpRule = {
|
|||
/** One row per scalar patch field, in emission order. */
|
||||
const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
|
||||
{ key: "woNumber", field: "woNumber", toValue: (value) => value as string },
|
||||
// Severity before type so Emergency/Reactive never land without required severity.
|
||||
{
|
||||
key: "severity",
|
||||
field: "severity",
|
||||
toValue: (value) => (value == null ? "" : String(value)),
|
||||
},
|
||||
{ key: "type", field: "workOrderType", toValue: (value) => value as string },
|
||||
{ key: "site", field: "siteCode", toValue: (value) => value as string },
|
||||
{ key: "status", field: "lifecycleStatus", toValue: (value) => value as string },
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import type { WorkOrderFormValues } from "@/domain/work-orders/types/work-order-detail";
|
||||
import type {
|
||||
WOType,
|
||||
WizardWOStatus,
|
||||
WorkOrderWizardDraft,
|
||||
import {
|
||||
effectiveWizardType,
|
||||
type WOType,
|
||||
type WizardWOStatus,
|
||||
type WorkOrderWizardDraft,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common";
|
||||
import { formatApptRange, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
|
@ -66,13 +67,14 @@ export function mapTypeToPriority(type: WOType | ""): string {
|
|||
}
|
||||
|
||||
function buildTitle(draft: WorkOrderWizardDraft): string {
|
||||
const typeLabel = effectiveWizardType(draft.type);
|
||||
if (draft.woNumber) {
|
||||
return `${draft.type || "Work Order"} — #${draft.woNumber}`;
|
||||
return `${typeLabel} — #${draft.woNumber}`;
|
||||
}
|
||||
if (draft.locationName) {
|
||||
return `${draft.type || "Work Order"} — ${draft.locationName}`;
|
||||
return `${typeLabel} — ${draft.locationName}`;
|
||||
}
|
||||
return draft.type || "Work Order";
|
||||
return typeLabel;
|
||||
}
|
||||
|
||||
function buildDescription(draft: WorkOrderWizardDraft): string {
|
||||
|
|
@ -103,7 +105,7 @@ export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrd
|
|||
workOrderNumber: draft.woNumber,
|
||||
title: buildTitle(draft),
|
||||
locationId: draft.locationId,
|
||||
priority: mapTypeToPriority(draft.type),
|
||||
priority: mapTypeToPriority(effectiveWizardType(draft.type)),
|
||||
status: mapWizardStatusToApi(draft.status),
|
||||
createdDate: todayIso(),
|
||||
dueDate: draft.dueDate,
|
||||
|
|
@ -118,6 +120,6 @@ export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrd
|
|||
trade: "",
|
||||
subTrade: "",
|
||||
vendorNTE: "",
|
||||
source: draft.type,
|
||||
source: effectiveWizardType(draft.type),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export function buildBoardDescription(draft: WorkOrderWizardDraft): string {
|
|||
export function mapWizardDraftToBoardCreatePayload(
|
||||
draft: WorkOrderWizardDraft,
|
||||
): WorkOrderBoardCreatePayload {
|
||||
const workOrderType = mapWizardTypeToApi(draft.type);
|
||||
const workOrderType = mapWizardTypeToApi(draft.type || "PM");
|
||||
|
||||
const siteCode = draft.siteCode.trim() || resolveSiteCode({ name: draft.locationName });
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
CompletionDocStatus,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { parseWorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { formatApptRange } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
|
@ -234,6 +235,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
|||
woNumber,
|
||||
site: site || location.slice(0, 8),
|
||||
type: readWoType(item),
|
||||
severity: parseWorkOrderSeverity(item.severity ?? item.Severity),
|
||||
dispatcherId,
|
||||
dispatcherName,
|
||||
scheduledOn,
|
||||
|
|
|
|||
|
|
@ -93,6 +93,8 @@ export interface WorkOrderBoardCreatePayload {
|
|||
pocNotes?: string;
|
||||
techPhone?: string;
|
||||
vendorNotes?: string;
|
||||
/** Severity 1–5 for Emergency/Reactive. */
|
||||
severity?: number;
|
||||
}
|
||||
|
||||
/** Payload for PATCH /workorders/{id}/board (one field per request). */
|
||||
|
|
@ -122,7 +124,8 @@ export type WorkOrderBoardEditableField =
|
|||
| "pm"
|
||||
| "serviceNotes"
|
||||
| "extraServices"
|
||||
| "flagColor";
|
||||
| "flagColor"
|
||||
| "severity";
|
||||
|
||||
/** Espelha WorkOrderBoardRowDto (JSON camelCase). */
|
||||
export interface WorkOrderBoardRowApi {
|
||||
|
|
@ -147,8 +150,12 @@ export interface WorkOrderBoardRowApi {
|
|||
color?: string | null;
|
||||
/** Board flag color (#RRGGBB) or null when cleared. */
|
||||
flagColor?: string | null;
|
||||
/** Severity 1–5 for Emergency/Reactive. */
|
||||
severity?: number | null;
|
||||
dueDate?: string | null;
|
||||
scheduledDate?: string | null;
|
||||
/** ISO date when work was completed; optional on list rows until BE confirms. */
|
||||
completedDate?: string | null;
|
||||
targetWeek?: string | null;
|
||||
scheduleWeekOnly?: boolean | null;
|
||||
dayGroup?: string | null;
|
||||
|
|
|
|||
75
src/domain/work-orders/types/work-order-severity.ts
Normal file
75
src/domain/work-orders/types/work-order-severity.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
export type WorkOrderSeverity = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
export const WORK_ORDER_SEVERITIES: readonly WorkOrderSeverity[] = [1, 2, 3, 4, 5];
|
||||
|
||||
/** Types that require an APM severity transcription. */
|
||||
export const SEVERITY_TYPES = ["Emergency", "Reactive"] as const;
|
||||
|
||||
export type SeverityWoType = (typeof SEVERITY_TYPES)[number];
|
||||
|
||||
export type SeverityToken = "critical" | "warning" | "safe";
|
||||
|
||||
export const SEVERITY_LEVELS: Record<
|
||||
WorkOrderSeverity,
|
||||
{
|
||||
label: string;
|
||||
description: string;
|
||||
deadline: string;
|
||||
afterHours: string;
|
||||
token: SeverityToken;
|
||||
}
|
||||
> = {
|
||||
1: {
|
||||
label: "SEV 1",
|
||||
description: "Business Critical Function Down",
|
||||
deadline: "Accept: 15 min · Respond: 2 hrs",
|
||||
afterHours: "Yes",
|
||||
token: "critical",
|
||||
},
|
||||
2: {
|
||||
label: "SEV 2",
|
||||
description: "Business Critical Function Impaired",
|
||||
deadline: "Accept: 30 min · Respond: 4 hrs",
|
||||
afterHours: "Yes",
|
||||
token: "critical",
|
||||
},
|
||||
3: {
|
||||
label: "SEV 3",
|
||||
description: "Group Productivity Impaired",
|
||||
deadline: "Accept: 2 hrs · Respond: 8 hrs (next day if after hours)",
|
||||
afterHours: "Approval required",
|
||||
token: "warning",
|
||||
},
|
||||
4: {
|
||||
label: "SEV 4",
|
||||
description: "Individual Productivity Impaired",
|
||||
deadline: "Accept: 12 hrs · Respond: 24 hrs (next day)",
|
||||
afterHours: "No",
|
||||
token: "safe",
|
||||
},
|
||||
5: {
|
||||
label: "SEV 5",
|
||||
description: "Productivity not immediately affected",
|
||||
deadline: "Accept: 12 hrs · Respond: 72 hrs",
|
||||
afterHours: "No",
|
||||
token: "safe",
|
||||
},
|
||||
};
|
||||
|
||||
export const SEVERITY_TOKEN_COLOR: Record<SeverityToken, string> = {
|
||||
critical: "var(--sla-critical, #DC2626)",
|
||||
warning: "var(--sla-warning, #D97706)",
|
||||
safe: "var(--sla-safe, #16A34A)",
|
||||
};
|
||||
|
||||
export function typeNeedsSeverity(type: string): boolean {
|
||||
return (SEVERITY_TYPES as readonly string[]).includes(type);
|
||||
}
|
||||
|
||||
export function parseWorkOrderSeverity(value: unknown): WorkOrderSeverity | null {
|
||||
const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
|
||||
if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) {
|
||||
return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { WizardWOStatus, WOType } from "./work-order-wizard";
|
||||
import type { WorkOrderSeverity } from "./work-order-severity";
|
||||
|
||||
export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
|
||||
|
||||
|
|
@ -9,6 +10,8 @@ export interface WorkOrderTableRow {
|
|||
woNumber: string;
|
||||
site: string;
|
||||
type: WOType | "";
|
||||
/** Severity 1–5 for Emergency/Reactive; null otherwise. */
|
||||
severity: WorkOrderSeverity | null;
|
||||
dispatcherId: string;
|
||||
dispatcherName: string;
|
||||
scheduledOn: string;
|
||||
|
|
@ -57,6 +60,7 @@ export interface WorkOrderTableDraft {
|
|||
site: string;
|
||||
woNumber: string;
|
||||
type: WOType | "";
|
||||
severity: WorkOrderSeverity | null;
|
||||
dispatcherId: string;
|
||||
scheduledOn: string;
|
||||
schedNotes: string;
|
||||
|
|
@ -84,6 +88,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
|
|||
site: "",
|
||||
woNumber: "",
|
||||
type: "",
|
||||
severity: null,
|
||||
dispatcherId: "",
|
||||
scheduledOn: "",
|
||||
schedNotes: "",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import { typeNeedsSeverity, type WorkOrderSeverity } from "./work-order-severity";
|
||||
|
||||
export type { WorkOrderSeverity } from "./work-order-severity";
|
||||
|
||||
export const WO_TYPES = ["PM", "Reactive", "Emergency", "Add-On", "Overdue"] as const;
|
||||
|
||||
export type WOType = (typeof WO_TYPES)[number];
|
||||
|
|
@ -24,6 +28,8 @@ export interface WorkOrderWizardDraft {
|
|||
siteCode: string;
|
||||
woNumber: string;
|
||||
type: WOType | "";
|
||||
/** Severity 1–5 for Emergency/Reactive; null otherwise. */
|
||||
severity: WorkOrderSeverity | null;
|
||||
assigneeId: string;
|
||||
assigneeName: string;
|
||||
scheduledOn: string;
|
||||
|
|
@ -58,6 +64,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
|||
siteCode: "",
|
||||
woNumber: "",
|
||||
type: "",
|
||||
severity: null,
|
||||
assigneeId: "",
|
||||
assigneeName: "",
|
||||
scheduledOn: "",
|
||||
|
|
@ -81,13 +88,22 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
|||
schedMode: undefined,
|
||||
};
|
||||
|
||||
/** WO number is optional; when provided must be digits only, 1–20 characters. */
|
||||
/** Blank numbers receive an SH provisional number from the backend; nonblank values are alphanumeric. */
|
||||
export function isWizardWoNumberValid(woNumber: string): boolean {
|
||||
const trimmed = woNumber.trim();
|
||||
if (!trimmed) {
|
||||
return true;
|
||||
}
|
||||
return /^\d{1,20}$/.test(trimmed);
|
||||
return trimmed.length <= 20 && /^[a-z0-9]+$/i.test(trimmed);
|
||||
}
|
||||
|
||||
/** Type is optional at creation; empty draft type defaults to PM. */
|
||||
export function effectiveWizardType(type: WOType | ""): WOType {
|
||||
return type || "PM";
|
||||
}
|
||||
|
||||
export function wizardNeedsSeverity(draft: WorkOrderWizardDraft): boolean {
|
||||
return typeNeedsSeverity(draft.type);
|
||||
}
|
||||
|
||||
export const WIZARD_STEPS: { title: string; nav: string; subtitle: string }[] = [
|
||||
|
|
@ -159,11 +175,22 @@ export const TIME_SLOTS: string[] = (() => {
|
|||
})();
|
||||
|
||||
export function canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean {
|
||||
return Boolean(draft.type) && isWizardWoNumberValid(draft.woNumber);
|
||||
if (!isWizardWoNumberValid(draft.woNumber)) {
|
||||
return false;
|
||||
}
|
||||
if (wizardNeedsSeverity(draft) && draft.severity == null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function canContinueWizardStep2(draft: WorkOrderWizardDraft): boolean {
|
||||
const hasSite = Boolean(draft.locationId && (draft.siteCode.trim() || draft.locationName.trim()));
|
||||
return Boolean(hasSite && draft.pocName.trim() && draft.pocPhone.trim());
|
||||
}
|
||||
|
||||
export function canCreateWorkOrderFromWizard(draft: WorkOrderWizardDraft): boolean {
|
||||
return Boolean(draft.type && draft.siteCode) && isWizardWoNumberValid(draft.woNumber);
|
||||
return canContinueWizardStep1(draft) && canContinueWizardStep2(draft);
|
||||
}
|
||||
|
||||
export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): boolean {
|
||||
|
|
@ -171,7 +198,7 @@ export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): bo
|
|||
return canContinueWizardStep1(draft);
|
||||
}
|
||||
if (step === 2) {
|
||||
return Boolean(draft.locationId && draft.siteCode);
|
||||
return canContinueWizardStep2(draft);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
16
src/domain/work-orders/utils/find-duplicate-wo-number-row.ts
Normal file
16
src/domain/work-orders/utils/find-duplicate-wo-number-row.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
export function findDuplicateWoNumberRow(
|
||||
woNumber: string,
|
||||
rows: WorkOrderTableRow[],
|
||||
excludeId?: string | number,
|
||||
): WorkOrderTableRow | undefined {
|
||||
const normalized = woNumber.trim();
|
||||
if (normalized === "") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return rows.find(
|
||||
(row) => row.woNumber === normalized && String(row.id) !== String(excludeId ?? ""),
|
||||
);
|
||||
}
|
||||
62
src/domain/work-orders/utils/get-inline-create-missing.ts
Normal file
62
src/domain/work-orders/utils/get-inline-create-missing.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/** Required fields for board inline create (Schedule On is optional — SH-109). */
|
||||
export type InlineCreateReqField = "site" | "woNumber" | "pocName" | "pocPhone";
|
||||
|
||||
export const INLINE_CREATE_REQ_LABELS: Record<InlineCreateReqField, string> = {
|
||||
site: "Site",
|
||||
woNumber: "WO #",
|
||||
pocName: "POC name",
|
||||
pocPhone: "POC phone",
|
||||
};
|
||||
|
||||
export type InlineCreateDraftFields = {
|
||||
site?: string;
|
||||
locationId?: string | number;
|
||||
woNumber?: string;
|
||||
pocName?: string;
|
||||
pocPhone?: string;
|
||||
};
|
||||
|
||||
export function getInlineCreateMissing(d: InlineCreateDraftFields): InlineCreateReqField[] {
|
||||
const miss: InlineCreateReqField[] = [];
|
||||
const hasSite = Boolean(d.site?.trim()) || Boolean(String(d.locationId ?? "").trim());
|
||||
if (!hasSite) {
|
||||
miss.push("site");
|
||||
}
|
||||
if (!d.woNumber?.trim()) {
|
||||
miss.push("woNumber");
|
||||
}
|
||||
if (!d.pocName?.trim()) {
|
||||
miss.push("pocName");
|
||||
}
|
||||
if (!d.pocPhone?.trim()) {
|
||||
miss.push("pocPhone");
|
||||
}
|
||||
return miss;
|
||||
}
|
||||
|
||||
/** Tooltip for Save: "Missing: Site, WO #" or "Save" when ready. */
|
||||
export function inlineCreateSaveHint(miss: InlineCreateReqField[]): string {
|
||||
if (miss.length === 0) {
|
||||
return "Save";
|
||||
}
|
||||
return `Missing: ${miss.map((m) => INLINE_CREATE_REQ_LABELS[m]).join(", ")}`;
|
||||
}
|
||||
|
||||
export function inlineCreateSiteError(miss: InlineCreateReqField[]): string {
|
||||
if (miss.includes("site")) {
|
||||
return "Site is required";
|
||||
}
|
||||
return "Contact name and phone are required";
|
||||
}
|
||||
|
||||
/** Concrete calendar day/range — week-only does not count (design hasScheduleDate). */
|
||||
export function draftHasScheduleDate(d: {
|
||||
scheduledOn?: string;
|
||||
scheduleWeekOnly?: boolean;
|
||||
schedMode?: string;
|
||||
}): boolean {
|
||||
if (d.scheduleWeekOnly || d.schedMode === "week") {
|
||||
return false;
|
||||
}
|
||||
return Boolean(d.scheduledOn?.trim());
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ export const SLIDE_OVER_EDIT_KEYS = [
|
|||
"targetWeek",
|
||||
"scheduleWeekOnly",
|
||||
"dueDate",
|
||||
"completedDate",
|
||||
"apptTime",
|
||||
"pm",
|
||||
"serviceNotes",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useController } from "react-hook-form";
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const rosterGet = vi.fn();
|
||||
const saveMutate = vi.fn();
|
||||
|
||||
vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({
|
||||
vendorCompanyRosterApi: { get: (...args: unknown[]) => rosterGet(...args) },
|
||||
|
|
@ -24,8 +25,6 @@ vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
|
|||
useVendorFacets: () => ({ data: { companies: [], trades: [] } }),
|
||||
}));
|
||||
|
||||
const saveMutate = vi.fn();
|
||||
|
||||
vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/domain/vendors/use-cases/use-save-vendor-company-roster")
|
||||
|
|
@ -202,10 +201,47 @@ describe("useVendorRosterForm load-error handling", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("useVendorRosterForm prototype defaults", () => {
|
||||
it("does not persist the prototype's untouched optional technician card", () => {
|
||||
const { result } = renderHook(
|
||||
() => useVendorRosterForm({ mode: "create", startWithTechnician: true }),
|
||||
{ wrapper: makeWrapper(createClient()) },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.submit({
|
||||
name: "Independent Vendor LLC",
|
||||
companyPhone: "(314) 555-0199",
|
||||
email: "",
|
||||
address: "",
|
||||
city: "",
|
||||
state: "",
|
||||
zip: "",
|
||||
googleMapsUrl: "",
|
||||
notes: "",
|
||||
technicians: [
|
||||
{
|
||||
contactName: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
preferredContact: "Phone",
|
||||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
expect(saveMutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ values: expect.objectContaining({ technicians: [] }) }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useVendorRosterForm stale-selection handling", () => {
|
||||
beforeEach(() => {
|
||||
rosterGet.mockReset();
|
||||
saveMutate.mockReset();
|
||||
});
|
||||
|
||||
it("keeps the newest company roster when two lookups resolve out of order", async () => {
|
||||
|
|
@ -280,97 +316,3 @@ describe("useVendorRosterForm stale-selection handling", () => {
|
|||
expect(result.current.name.field.value).toBe("Draft Vendor");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useVendorRosterForm blank technician filtering", () => {
|
||||
beforeEach(() => {
|
||||
rosterGet.mockReset();
|
||||
saveMutate.mockReset();
|
||||
});
|
||||
|
||||
it("omits blank new technician rows from the save payload", () => {
|
||||
const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), {
|
||||
wrapper: makeWrapper(createClient()),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.submit({
|
||||
name: "Gateway Plumbing",
|
||||
companyPhone: "(314) 555-0100",
|
||||
email: "dispatch@gateway.test",
|
||||
address: "",
|
||||
city: "",
|
||||
state: "",
|
||||
zip: "",
|
||||
googleMapsUrl: "",
|
||||
notes: "",
|
||||
technicians: [
|
||||
{
|
||||
contactName: "Taylor",
|
||||
phone: "(314) 555-0199",
|
||||
email: "taylor@gateway.test",
|
||||
tradeSpecialties: "Plumbing",
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
contactName: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
expect(saveMutate).toHaveBeenCalledTimes(1);
|
||||
const payload = saveMutate.mock.calls[0]?.[0] as {
|
||||
values: { technicians: Array<{ contactName: string }> };
|
||||
};
|
||||
expect(payload.values.technicians).toHaveLength(1);
|
||||
expect(payload.values.technicians[0]?.contactName).toBe("Taylor");
|
||||
});
|
||||
|
||||
it("keeps existing technicians with an id even when contact fields are blank", () => {
|
||||
const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), {
|
||||
wrapper: makeWrapper(createClient()),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.submit({
|
||||
name: "Gateway Plumbing",
|
||||
companyPhone: "(314) 555-0100",
|
||||
email: "dispatch@gateway.test",
|
||||
address: "",
|
||||
city: "",
|
||||
state: "",
|
||||
zip: "",
|
||||
googleMapsUrl: "",
|
||||
notes: "",
|
||||
technicians: [
|
||||
{
|
||||
id: 9,
|
||||
contactName: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const payload = saveMutate.mock.calls[0]?.[0] as {
|
||||
values: { technicians: Array<{ id?: number }> };
|
||||
};
|
||||
expect(payload.values.technicians).toEqual([
|
||||
{
|
||||
id: 9,
|
||||
contactName: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,11 +49,11 @@ function rosterWith(technicians: Array<Record<string, unknown>>) {
|
|||
};
|
||||
}
|
||||
|
||||
describe("VendorDetailDrawer preferred-contact display", () => {
|
||||
describe("VendorDetailDrawer selected-technician display", () => {
|
||||
it("renders no preference label when preferredContact is absent", () => {
|
||||
useVendorCompanyRoster.mockReturnValue(
|
||||
rosterWith([
|
||||
{ id: 7, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 },
|
||||
{ id: 1, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 },
|
||||
]),
|
||||
);
|
||||
|
||||
|
|
@ -62,15 +62,15 @@ describe("VendorDetailDrawer preferred-contact display", () => {
|
|||
withAuth: false,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Adam")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the preference label when an explicit preferredContact is present", () => {
|
||||
it("keeps the retired preferred-contact value out of the detail view", () => {
|
||||
useVendorCompanyRoster.mockReturnValue(
|
||||
rosterWith([
|
||||
{
|
||||
id: 7,
|
||||
id: 1,
|
||||
contactName: "Adam",
|
||||
phone: "314-555-0198",
|
||||
preferredContact: "Email",
|
||||
|
|
@ -85,6 +85,52 @@ describe("VendorDetailDrawer preferred-contact display", () => {
|
|||
withAuth: false,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Prefers Email")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the clicked row when the roster has no matching stable id", () => {
|
||||
useVendorCompanyRoster.mockReturnValue(
|
||||
rosterWith([
|
||||
{
|
||||
id: 7,
|
||||
contactName: "Adam Whyte",
|
||||
phone: "999-999-9999",
|
||||
isActive: true,
|
||||
totalJobs: 99,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
renderWithProviders(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
|
||||
route: "/vendors",
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
expect(screen.getByText("0")).toBeInTheDocument();
|
||||
expect(screen.queryByText("999-999-9999")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not expose a status control for a different technician", () => {
|
||||
useVendorCompanyRoster.mockReturnValue(
|
||||
rosterWith([
|
||||
{
|
||||
id: 7,
|
||||
contactName: "Different Technician",
|
||||
phone: "314-555-0101",
|
||||
email: "",
|
||||
tradeSpecialties: "Plumbing",
|
||||
isActive: true,
|
||||
totalJobs: 10,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<VendorDetailDrawer open={true} vendor={vendor} startInEdit={true} onClose={vi.fn()} />,
|
||||
{ route: "/vendors", withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("switch", { name: "Active status" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,21 +1,14 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import VendorsListPage from "@/app/(protected)/vendors/index";
|
||||
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
||||
import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const useVendorsList = vi.fn();
|
||||
const useVendorCompanyRoster = vi.fn();
|
||||
const useVendorDeactivationImpact = vi.fn();
|
||||
const mutate = vi.fn();
|
||||
const navigate = vi.fn();
|
||||
|
||||
vi.mock("react-router", async () => {
|
||||
const actual = await vi.importActual("react-router");
|
||||
return { ...actual, useNavigate: () => navigate };
|
||||
});
|
||||
|
||||
vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({
|
||||
useVendorsList: (...args: unknown[]) => useVendorsList(...args),
|
||||
}));
|
||||
|
|
@ -61,6 +54,32 @@ const inactiveVendor = {
|
|||
isActive: false,
|
||||
};
|
||||
|
||||
const activeRoster: VendorCompanyRoster = {
|
||||
companyId: "co-1",
|
||||
rowVersion: "rv-1",
|
||||
name: "Gateway Plumbing",
|
||||
companyPhone: "(314) 555-0100",
|
||||
email: "dispatch@gatewayplumbing.com",
|
||||
address: "",
|
||||
city: "St. Louis",
|
||||
state: "MO",
|
||||
zip: "",
|
||||
googleMapsUrl: "",
|
||||
notes: "",
|
||||
technicians: [
|
||||
{
|
||||
id: 1,
|
||||
contactName: "Adam Whyte",
|
||||
phone: "(314) 555-0198",
|
||||
email: "dispatch@gatewayplumbing.com",
|
||||
preferredContact: "Email",
|
||||
tradeSpecialties: "Backflow Preventers",
|
||||
isActive: true,
|
||||
totalJobs: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function result(items: VendorListItem[], totalCount: number) {
|
||||
return {
|
||||
data: { items, totalCount, page: 1, pageSize: 12 },
|
||||
|
|
@ -71,7 +90,13 @@ function result(items: VendorListItem[], totalCount: number) {
|
|||
}
|
||||
|
||||
function setupDefaults() {
|
||||
useVendorCompanyRoster.mockReturnValue({ data: undefined, isLoading: false, error: null });
|
||||
useVendorCompanyRoster.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
useVendorDeactivationImpact.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
|
|
@ -80,7 +105,7 @@ function setupDefaults() {
|
|||
}
|
||||
|
||||
describe("VendorsListPage", () => {
|
||||
it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => {
|
||||
it("renders prototype-aligned status and two row actions in both tabs", async () => {
|
||||
setupDefaults();
|
||||
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||
if (params.pageSize === 1) return result([], params.isActive ? 1 : 1);
|
||||
|
|
@ -91,11 +116,20 @@ describe("VendorsListPage", () => {
|
|||
|
||||
expect(screen.getByRole("heading", { name: "Vendors / Technicians" })).toBeInTheDocument();
|
||||
expect(screen.getByText("St. Louis, MO")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Deactivate vendor" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Status" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("cell", { name: "Active" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "View vendor Gateway Plumbing" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
|
||||
|
||||
expect(screen.getByText("Drain Cleaning Bros")).toBeInTheDocument();
|
||||
expect(screen.getByRole("cell", { name: "Inactive" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -114,39 +148,18 @@ describe("VendorsListPage", () => {
|
|||
|
||||
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||
|
||||
expect(screen.getByText(/25 active vendor entries/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/3 active vendor entries/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/25 active technicians/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the detail drawer when a row is clicked or activated by keyboard", async () => {
|
||||
setupDefaults();
|
||||
useVendorCompanyRoster.mockReturnValue({
|
||||
data: {
|
||||
companyId: "co-1",
|
||||
rowVersion: "rv-1",
|
||||
name: "Gateway Plumbing",
|
||||
companyPhone: "(314) 555-0198",
|
||||
email: "dispatch@gatewayplumbing.com",
|
||||
address: "",
|
||||
city: "St. Louis",
|
||||
state: "MO",
|
||||
zip: "",
|
||||
googleMapsUrl: "",
|
||||
notes: "",
|
||||
technicians: [
|
||||
{
|
||||
id: 1,
|
||||
contactName: "Adam Whyte",
|
||||
phone: "(314) 555-0198",
|
||||
email: "dispatch@gatewayplumbing.com",
|
||||
preferredContact: "Email",
|
||||
tradeSpecialties: "Backflow Preventers",
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
data: activeRoster,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||
if (params.pageSize === 1) return result([], 1);
|
||||
|
|
@ -161,11 +174,18 @@ describe("VendorsListPage", () => {
|
|||
row.focus();
|
||||
await userEvent.keyboard("{Enter}");
|
||||
|
||||
expect(screen.getByRole("heading", { level: 2, name: "Gateway Plumbing" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { level: 2, name: "Adam Whyte" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blocks deactivation when the preflight reports open work orders", async () => {
|
||||
setupDefaults();
|
||||
useVendorCompanyRoster.mockReturnValue({
|
||||
data: activeRoster,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
useVendorDeactivationImpact.mockReturnValue({
|
||||
data: {
|
||||
vendorId: 1,
|
||||
|
|
@ -189,7 +209,8 @@ describe("VendorsListPage", () => {
|
|||
|
||||
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Deactivate vendor" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
||||
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
|
||||
|
||||
expect(
|
||||
screen.getByText(/cannot be deactivated because it still has open work orders/),
|
||||
|
|
@ -199,9 +220,51 @@ describe("VendorsListPage", () => {
|
|||
expect(mutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("navigates to the vendor edit route when the table Edit control is clicked", async () => {
|
||||
it("preserves inline edits when deactivation is cancelled", async () => {
|
||||
setupDefaults();
|
||||
navigate.mockClear();
|
||||
useVendorCompanyRoster.mockReturnValue({
|
||||
data: activeRoster,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
useVendorDeactivationImpact.mockReturnValue({
|
||||
data: { vendorId: 1, canDeactivate: true, openWorkOrders: [] },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||
if (params.pageSize === 1) return result([], 1);
|
||||
return params.isActive ? result([activeVendor], 1) : result([], 0);
|
||||
});
|
||||
|
||||
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
||||
const company = screen.getByRole("textbox", { name: "Company" });
|
||||
await userEvent.clear(company);
|
||||
await userEvent.type(company, "Draft Company Name");
|
||||
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
|
||||
await userEvent.click(
|
||||
within(screen.getByRole("dialog", { name: "Deactivate Vendor" })).getByRole("button", {
|
||||
name: "Cancel",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(company).toBeInTheDocument();
|
||||
expect(company).toHaveValue("Draft Company Name");
|
||||
}, 10_000);
|
||||
|
||||
it("opens inline edit when the table Edit control is clicked", async () => {
|
||||
setupDefaults();
|
||||
useVendorCompanyRoster.mockReturnValue({
|
||||
data: activeRoster,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||
if (params.pageSize === 1) return result([], 1);
|
||||
return params.isActive ? result([activeVendor], 1) : result([], 0);
|
||||
|
|
@ -211,16 +274,20 @@ describe("VendorsListPage", () => {
|
|||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith("/vendors/edit/1");
|
||||
expect(
|
||||
screen.queryByRole("heading", { level: 2, name: "Gateway Plumbing" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing");
|
||||
});
|
||||
|
||||
it("navigates to the company route when editing a vendor without a vendor id", async () => {
|
||||
it("opens inline company edit when the row has no vendor id", async () => {
|
||||
setupDefaults();
|
||||
navigate.mockClear();
|
||||
const companyOnlyVendor = { ...activeVendor, id: null, companyId: "co-9" };
|
||||
useVendorCompanyRoster.mockReturnValue({
|
||||
data: { ...activeRoster, companyId: "co-9", technicians: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||
if (params.pageSize === 1) return result([], 1);
|
||||
return params.isActive ? result([companyOnlyVendor], 1) : result([], 0);
|
||||
|
|
@ -230,6 +297,7 @@ describe("VendorsListPage", () => {
|
|||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith("/vendors/company/co-9");
|
||||
expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildInlineDraftCreateInput } from "@/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input";
|
||||
import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import { EMPTY_TABLE_DRAFT } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
describe("buildInlineDraftCreateInput", () => {
|
||||
it("defaults type to PM and omits schedule flags when undated", () => {
|
||||
const draft = {
|
||||
...EMPTY_TABLE_DRAFT,
|
||||
site: "BK5",
|
||||
locationId: "9",
|
||||
woNumber: "25001",
|
||||
pocName: "Jane",
|
||||
pocPhone: "555-0100",
|
||||
scheduleWeekOnly: true,
|
||||
targetWeek: "",
|
||||
scheduledOn: "",
|
||||
};
|
||||
|
||||
const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
|
||||
|
||||
expect(input.type).toBe("PM");
|
||||
expect(input.scheduleWeekOnly).toBe(false);
|
||||
expect(input.scheduledOn).toBe("");
|
||||
expect(input.targetWeek).toBe("");
|
||||
|
||||
const payload = mapWizardDraftToBoardCreatePayload(input);
|
||||
expect(payload.scheduledDate).toBeUndefined();
|
||||
expect(payload.targetWeek).toBeUndefined();
|
||||
expect(payload.scheduleWeekOnly).toBeUndefined();
|
||||
expect(payload.woNumber).toBe("25001");
|
||||
expect(payload.pocName).toBe("Jane");
|
||||
});
|
||||
|
||||
it("keeps week-only schedule when targetWeek is set", () => {
|
||||
const draft = {
|
||||
...EMPTY_TABLE_DRAFT,
|
||||
site: "BK5",
|
||||
locationId: "9",
|
||||
woNumber: "25002",
|
||||
type: "Reactive" as const,
|
||||
pocName: "Jane",
|
||||
pocPhone: "555-0100",
|
||||
scheduleWeekOnly: true,
|
||||
targetWeek: "2026-06-22",
|
||||
};
|
||||
|
||||
const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
|
||||
const payload = mapWizardDraftToBoardCreatePayload(input);
|
||||
|
||||
expect(payload.scheduleWeekOnly).toBe(true);
|
||||
expect(payload.targetWeek).toBe("2026-06-22");
|
||||
expect(payload.scheduledDate).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -28,11 +28,12 @@ vi.mock("@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast"
|
|||
const SCHEDULED_ON = "2026-06-01";
|
||||
|
||||
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
const defaults: WorkOrderTableRow = {
|
||||
id: 42,
|
||||
woNumber: "WO-42",
|
||||
site: "SEA1",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Dispatcher",
|
||||
scheduledOn: SCHEDULED_ON,
|
||||
|
|
@ -71,7 +72,11 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
rowVersion: "",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
};
|
||||
return {
|
||||
...defaults,
|
||||
...overrides,
|
||||
severity: overrides.severity !== undefined ? overrides.severity : defaults.severity,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const existingRow = { id: "wo-99", woNumber: "12345678901" } as WorkOrderTableRow;
|
||||
|
||||
describe("DuplicateWoNumberDialog", () => {
|
||||
it("shows the duplicate title and work order number when open", () => {
|
||||
renderWithProviders(
|
||||
<DuplicateWoNumberDialog
|
||||
open
|
||||
existing={existingRow}
|
||||
onDismiss={vi.fn()}
|
||||
onViewExisting={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByText("Duplicate Work Order Number")).toBeInTheDocument();
|
||||
expect(screen.getByText(/12345678901/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onViewExisting when View existing is clicked", () => {
|
||||
const onViewExisting = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<DuplicateWoNumberDialog
|
||||
open
|
||||
existing={existingRow}
|
||||
onDismiss={vi.fn()}
|
||||
onViewExisting={onViewExisting}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "View existing" }));
|
||||
|
||||
expect(onViewExisting).toHaveBeenCalledWith(existingRow);
|
||||
});
|
||||
|
||||
it("calls onDismiss when Dismiss is clicked", () => {
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<DuplicateWoNumberDialog
|
||||
open
|
||||
existing={existingRow}
|
||||
onDismiss={onDismiss}
|
||||
onViewExisting={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -45,6 +45,7 @@ function baseRow(): WorkOrderTableRow {
|
|||
woNumber: "WO-7",
|
||||
site: "SEA1",
|
||||
type: "Emergency",
|
||||
severity: 1,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Dispatcher",
|
||||
scheduledOn: "2026-06-01",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const existingRow = { id: "wo-99", woNumber: "12345678901" } as WorkOrderTableRow;
|
||||
const findByWoNumber = vi.fn();
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-create-work-order-from-wizard", () => ({
|
||||
useCreateWorkOrderFromWizard: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||
useWorkOrderVendors: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/locations/use-cases/use-location-sites", () => ({
|
||||
useLocationSites: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
|
||||
useLocationDetail: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
|
||||
useDropdownOptionsByCategory: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
|
||||
workOrdersApi: {
|
||||
findByWoNumber: (...args: unknown[]) => findByWoNumber(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("NewWoWizard duplicate wo number", () => {
|
||||
it("shows the duplicate dialog when the wizard wo number matches an existing row", async () => {
|
||||
renderWithProviders(
|
||||
<NewWoWizard
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
users={[]}
|
||||
existingRows={[existingRow]}
|
||||
onViewExistingWo={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const woNumberInput = screen.getByPlaceholderText("Leave blank for SH provisional number");
|
||||
fireEvent.change(woNumberInput, {
|
||||
target: { value: "12345678901" },
|
||||
});
|
||||
fireEvent.blur(woNumberInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Duplicate Work Order Number")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/is already assigned to another work order/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("finds a duplicate outside the loaded board rows", async () => {
|
||||
const offPageRow = {
|
||||
id: "wo-off-page",
|
||||
woNumber: "98765432109",
|
||||
} as WorkOrderTableRow;
|
||||
findByWoNumber.mockResolvedValueOnce(offPageRow);
|
||||
|
||||
renderWithProviders(
|
||||
<NewWoWizard
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
users={[]}
|
||||
existingRows={[]}
|
||||
onViewExistingWo={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const woNumberInput = screen.getByPlaceholderText("Leave blank for SH provisional number");
|
||||
fireEvent.change(woNumberInput, {
|
||||
target: { value: "98765432109" },
|
||||
});
|
||||
fireEvent.blur(woNumberInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Duplicate Work Order Number")).toBeInTheDocument();
|
||||
});
|
||||
expect(findByWoNumber).toHaveBeenCalledWith("98765432109");
|
||||
});
|
||||
});
|
||||
|
|
@ -15,11 +15,12 @@ vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
|
|||
}));
|
||||
|
||||
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
const defaults: WorkOrderTableRow = {
|
||||
id: 42,
|
||||
woNumber: "WO-42",
|
||||
site: "SEA1",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Dispatcher",
|
||||
scheduledOn: "2026-06-01",
|
||||
|
|
@ -58,7 +59,11 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
rowVersion: "",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
};
|
||||
return {
|
||||
...defaults,
|
||||
...overrides,
|
||||
severity: overrides.severity !== undefined ? overrides.severity : defaults.severity,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSiteDialogState } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
const detailById = new Map<string, Location>();
|
||||
|
||||
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
|
||||
useLocationDetail: (id: string | undefined) => ({
|
||||
data: id && detailById.has(id) ? detailById.get(id) : undefined,
|
||||
isLoading: Boolean(id && !detailById.has(id)),
|
||||
isError: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const SITES: LocationOption[] = [
|
||||
{ id: "loc-1", name: "BK5 · Dallas", siteCode: "BK5", city: "Dallas", state: "TX" },
|
||||
{ id: "loc-2", name: "SEA1 · Seattle", siteCode: "SEA1", city: "Seattle", state: "WA" },
|
||||
];
|
||||
|
||||
function locationDetail(id: string, contact: string, phone: string): Location {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
contact,
|
||||
phone,
|
||||
address: "100 Main St",
|
||||
city: "Dallas",
|
||||
state: "TX",
|
||||
zipCode: "75001",
|
||||
};
|
||||
}
|
||||
|
||||
function renderSiteDialogState() {
|
||||
return renderHook(() =>
|
||||
useSiteDialogState({
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
value: "",
|
||||
locationId: "",
|
||||
pocName: "",
|
||||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
sites: SITES,
|
||||
createMode: true,
|
||||
onSave: vi.fn(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("useSiteDialogState POC autofill", () => {
|
||||
beforeEach(() => {
|
||||
detailById.clear();
|
||||
});
|
||||
|
||||
it("preserves POC typed before deferred location detail resolves", () => {
|
||||
const { result, rerender } = renderSiteDialogState();
|
||||
|
||||
act(() => {
|
||||
result.current.handlePick("loc-1");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setPn("Dispatcher Typed");
|
||||
result.current.setPp("999-000-1111");
|
||||
});
|
||||
|
||||
expect(result.current.pn).toBe("Dispatcher Typed");
|
||||
expect(result.current.pp).toBe("999-000-1111");
|
||||
|
||||
detailById.set("loc-1", locationDetail("loc-1", "Site Contact", "214-555-0111"));
|
||||
act(() => {
|
||||
rerender();
|
||||
});
|
||||
|
||||
expect(result.current.pn).toBe("Dispatcher Typed");
|
||||
expect(result.current.pp).toBe("999-000-1111");
|
||||
});
|
||||
|
||||
it("clears prior POC on site switch then autofills from the new site detail", () => {
|
||||
const { result, rerender } = renderSiteDialogState();
|
||||
|
||||
act(() => {
|
||||
result.current.handlePick("loc-1");
|
||||
});
|
||||
|
||||
detailById.set("loc-1", locationDetail("loc-1", "Contact A", "111-111-1111"));
|
||||
act(() => {
|
||||
rerender();
|
||||
});
|
||||
|
||||
expect(result.current.pn).toBe("Contact A");
|
||||
expect(result.current.pp).toBe("111-111-1111");
|
||||
|
||||
act(() => {
|
||||
result.current.handlePick("loc-2");
|
||||
});
|
||||
|
||||
expect(result.current.locId).toBe("loc-2");
|
||||
expect(result.current.pn).toBe("");
|
||||
expect(result.current.pp).toBe("");
|
||||
|
||||
detailById.set("loc-2", locationDetail("loc-2", "Contact B", "222-222-2222"));
|
||||
act(() => {
|
||||
rerender();
|
||||
});
|
||||
|
||||
expect(result.current.pn).toBe("Contact B");
|
||||
expect(result.current.pp).toBe("222-222-2222");
|
||||
});
|
||||
});
|
||||
|
|
@ -10,6 +10,7 @@ function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
woNumber: "100",
|
||||
site: "SITE",
|
||||
type: "Emergency",
|
||||
severity: null,
|
||||
dispatcherId: "d1",
|
||||
dispatcherName: "Alex",
|
||||
scheduledOn: "2026-07-16",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useWoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
const existingRow = { id: "wo-99", woNumber: "12345678901" } as WorkOrderTableRow;
|
||||
|
||||
function buildTableData() {
|
||||
return {
|
||||
dragState: { current: { dragId: null, overId: null } },
|
||||
setDragViz: vi.fn(),
|
||||
reorderRows: vi.fn(),
|
||||
endDrag: vi.fn(),
|
||||
setUnschedOpen: vi.fn(),
|
||||
triggerFlash: vi.fn(),
|
||||
setSelectedId: vi.fn(),
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("useWoTableRowHandlers onDuplicateFound", () => {
|
||||
it("delegates to onDuplicateFound instead of opening the slide-over", () => {
|
||||
const onDuplicateFound = vi.fn();
|
||||
const onOpenSlideOver = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWoTableRowHandlers({
|
||||
tableData: buildTableData(),
|
||||
weekMonday: "2026-07-20",
|
||||
onWeekChange: vi.fn(),
|
||||
onOpenSlideOver,
|
||||
onOpenMedia: vi.fn(),
|
||||
onPatch: vi.fn(),
|
||||
onScheduleHighlight: vi.fn(),
|
||||
onDuplicateFound,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onDuplicateFound?.(existingRow);
|
||||
|
||||
expect(onDuplicateFound).toHaveBeenCalledWith(existingRow);
|
||||
expect(onOpenSlideOver).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -14,9 +14,11 @@ const unscheduledRow: WorkOrderTableRow = {
|
|||
woNumber: "WO-42",
|
||||
site: "CHI-1042",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Test Dispatcher",
|
||||
scheduledOn: "",
|
||||
completedDate: "",
|
||||
targetWeek: "",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
|
|
@ -38,7 +40,6 @@ const unscheduledRow: WorkOrderTableRow = {
|
|||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
dueDate: "2026-07-15",
|
||||
completedDate: "",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
originalWeek: "",
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
woNumber: "WO-42",
|
||||
site: "SEA1",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Dispatcher",
|
||||
scheduledOn: "2026-07-20",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ const canceledRow: WorkOrderTableRow = {
|
|||
woNumber: "WO-77",
|
||||
site: "CHI-1042",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Test Dispatcher",
|
||||
scheduledOn: "2026-07-20",
|
||||
|
|
@ -216,9 +217,15 @@ describe("WorkOrdersListPage board-core public interface", () => {
|
|||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /type & schedule/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/pick a type to continue/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /continue/i })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByPlaceholderText(/leave blank for sh provisional number/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /continue/i })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: /continue/i }));
|
||||
expect(screen.queryByText(/wo number is required/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /location & service/i })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^back$/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByRole("dialog"));
|
||||
|
|
|
|||
52
src/test/app/v/dispatch-snapshot-details.test.tsx
Normal file
52
src/test/app/v/dispatch-snapshot-details.test.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DispatchSnapshotDetails } from "@/app/v/[token]/dispatch/_components/dispatch-snapshot-details";
|
||||
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
function buildData(
|
||||
overrides: Partial<VendorPortalDispatchDetail> = {},
|
||||
): VendorPortalDispatchDetail {
|
||||
return {
|
||||
id: 9,
|
||||
dispatchNumber: "DSP-9",
|
||||
status: "Verified",
|
||||
nteAmount: 500,
|
||||
checklist: [],
|
||||
comments: [],
|
||||
upliftRequests: [],
|
||||
signoffs: [],
|
||||
documents: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DispatchSnapshotDetails locked banner", () => {
|
||||
it.each(["Verified", "Cancelled", "Refused"])(
|
||||
"states that comments remain open when status is %s",
|
||||
(status) => {
|
||||
renderWithProviders(<DispatchSnapshotDetails data={buildData({ status })} locked />, {
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
new RegExp(
|
||||
`This dispatch is ${status.toLowerCase()} and locked to further changes\\. Comments remain open\\.`,
|
||||
"i",
|
||||
),
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/sign-off/i)).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("hides the locked banner when the dispatch is not locked", () => {
|
||||
renderWithProviders(
|
||||
<DispatchSnapshotDetails data={buildData({ status: "In Progress" })} locked={false} />,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/locked to further changes/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,32 +1,7 @@
|
|||
import { createElement, type ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getSingleStatusOnlyChange,
|
||||
useSaveVendorCompanyRoster,
|
||||
} from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getSingleStatusOnlyChange } from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
|
||||
import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor";
|
||||
|
||||
const updateVendor = vi.fn();
|
||||
|
||||
vi.mock("@/domain/vendors/api/vendors-api", () => ({
|
||||
vendorsApi: {
|
||||
update: (...args: unknown[]) => updateVendor(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({
|
||||
vendorCompanyRosterApi: {
|
||||
update: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-toastify", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const roster: VendorCompanyRoster = {
|
||||
companyId: 10,
|
||||
rowVersion: "rv-1",
|
||||
|
|
@ -94,49 +69,3 @@ describe("getSingleStatusOnlyChange", () => {
|
|||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSaveVendorCompanyRoster status-only deactivation gate", () => {
|
||||
beforeEach(() => {
|
||||
updateVendor.mockReset();
|
||||
updateVendor.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("rejects sparse Active→Inactive updates and still allows reactivation", async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
createElement(QueryClientProvider, { client }, children);
|
||||
|
||||
const { result } = renderHook(() => useSaveVendorCompanyRoster(), { wrapper });
|
||||
|
||||
const activeRoster: VendorCompanyRoster = {
|
||||
...roster,
|
||||
technicians: [{ ...roster.technicians[0], isActive: true }],
|
||||
};
|
||||
|
||||
await expect(
|
||||
result.current.mutateAsync({
|
||||
mode: "update",
|
||||
values: {
|
||||
...values,
|
||||
technicians: [{ ...values.technicians[0], isActive: false }],
|
||||
},
|
||||
companyId: 10,
|
||||
rowVersion: "rv-1",
|
||||
originalRoster: activeRoster,
|
||||
}),
|
||||
).rejects.toThrow("Use Deactivate to check open work orders before inactivating a technician.");
|
||||
expect(updateVendor).not.toHaveBeenCalled();
|
||||
|
||||
await result.current.mutateAsync({
|
||||
mode: "update",
|
||||
values: {
|
||||
...values,
|
||||
technicians: [{ ...values.technicians[0], isActive: true }],
|
||||
},
|
||||
companyId: 10,
|
||||
rowVersion: "rv-1",
|
||||
originalRoster: roster,
|
||||
});
|
||||
expect(updateVendor).toHaveBeenCalledWith(7, { isActive: true });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -299,6 +299,157 @@ describe("workOrdersApi.searchBoard", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.findByWoNumber", () => {
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
});
|
||||
|
||||
it("searches globally without the active board filters", async () => {
|
||||
apiGet.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 505,
|
||||
woNumber: "10000001235",
|
||||
lifecycleStatusLabel: "Incomplete",
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
page: 0,
|
||||
pageSize: 100,
|
||||
});
|
||||
|
||||
const result = await workOrdersApi.findByWoNumber("10000001235");
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith(
|
||||
API_PATHS.workOrder.boardSearch,
|
||||
expect.objectContaining({
|
||||
searchParams: expect.any(URLSearchParams),
|
||||
}),
|
||||
);
|
||||
const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams;
|
||||
expect(Object.fromEntries(searchParams)).toEqual({
|
||||
search: "10000001235",
|
||||
page: "0",
|
||||
pageSize: "100",
|
||||
});
|
||||
expect(result).toMatchObject({ id: 505, woNumber: "10000001235" });
|
||||
});
|
||||
|
||||
it("pages through fuzzy search until the exact WO number is found", async () => {
|
||||
apiGet
|
||||
.mockResolvedValueOnce({
|
||||
items: Array.from({ length: 100 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
woNumber: `fuzzy-match-${index}`,
|
||||
lifecycleStatusLabel: "Incomplete",
|
||||
})),
|
||||
totalCount: 101,
|
||||
page: 0,
|
||||
pageSize: 100,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
id: 999,
|
||||
woNumber: "10000009999",
|
||||
lifecycleStatusLabel: "Incomplete",
|
||||
},
|
||||
],
|
||||
totalCount: 101,
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
|
||||
const result = await workOrdersApi.findByWoNumber("10000009999");
|
||||
|
||||
expect(apiGet).toHaveBeenCalledTimes(2);
|
||||
const secondParams = apiGet.mock.calls[1]?.[1]?.searchParams as URLSearchParams;
|
||||
expect(Object.fromEntries(secondParams)).toMatchObject({
|
||||
search: "10000009999",
|
||||
page: "1",
|
||||
pageSize: "100",
|
||||
});
|
||||
expect(result).toMatchObject({ id: 999, woNumber: "10000009999" });
|
||||
});
|
||||
|
||||
it("skips remote search for empty WO numbers", async () => {
|
||||
const result = await workOrdersApi.findByWoNumber(" ");
|
||||
expect(apiGet).not.toHaveBeenCalled();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("finds a one-character WO number duplicate outside loaded board rows", async () => {
|
||||
apiGet.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 7,
|
||||
woNumber: "9",
|
||||
lifecycleStatusLabel: "Incomplete",
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
page: 0,
|
||||
pageSize: 100,
|
||||
});
|
||||
|
||||
const result = await workOrdersApi.findByWoNumber("9");
|
||||
|
||||
expect(apiGet).toHaveBeenCalledTimes(1);
|
||||
const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams;
|
||||
expect(Object.fromEntries(searchParams)).toEqual({
|
||||
search: "9",
|
||||
page: "0",
|
||||
pageSize: "100",
|
||||
});
|
||||
expect(result).toMatchObject({ id: 7, woNumber: "9" });
|
||||
});
|
||||
|
||||
it("throws when the safety page limit is hit before exhausting results", async () => {
|
||||
apiGet.mockResolvedValue({
|
||||
items: Array.from({ length: 100 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
woNumber: `fuzzy-match-${index}`,
|
||||
lifecycleStatusLabel: "Incomplete",
|
||||
})),
|
||||
totalCount: 10_000,
|
||||
page: 0,
|
||||
pageSize: 100,
|
||||
});
|
||||
|
||||
await expect(workOrdersApi.findByWoNumber("10000005555")).rejects.toThrow(/safety page limit/i);
|
||||
expect(apiGet).toHaveBeenCalledTimes(50);
|
||||
});
|
||||
|
||||
it("returns undefined after exhausting all fuzzy search pages", async () => {
|
||||
apiGet
|
||||
.mockResolvedValueOnce({
|
||||
items: Array.from({ length: 100 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
woNumber: `fuzzy-match-${index}`,
|
||||
lifecycleStatusLabel: "Incomplete",
|
||||
})),
|
||||
totalCount: 150,
|
||||
page: 0,
|
||||
pageSize: 100,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: Array.from({ length: 50 }, (_, index) => ({
|
||||
id: index + 101,
|
||||
woNumber: `fuzzy-match-${index + 100}`,
|
||||
lifecycleStatusLabel: "Incomplete",
|
||||
})),
|
||||
totalCount: 150,
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
|
||||
const result = await workOrdersApi.findByWoNumber("10000005555");
|
||||
|
||||
expect(apiGet).toHaveBeenCalledTimes(2);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.patchBoardField", () => {
|
||||
beforeEach(() => {
|
||||
apiPatchFn.mockReset();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { WO_TABLE_COLS } from "@/app/(protected)/workorders/_components/list/table/wo-table-cols";
|
||||
import { expandScalarBoardOps } from "@/domain/work-orders/mappers/expand-scalar-board-ops";
|
||||
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
|
||||
|
||||
describe("COMPLETED DATE board column wiring", () => {
|
||||
it("includes COMPLETED DATE column after COMP DOC", () => {
|
||||
const keys = WO_TABLE_COLS.map((col) => col.key);
|
||||
expect(keys).toContain("completed");
|
||||
expect(keys.indexOf("completed")).toBeGreaterThan(keys.indexOf("doc"));
|
||||
});
|
||||
|
||||
it("expands completedDate patch to board field op", () => {
|
||||
const ops = expandScalarBoardOps({ completedDate: "2026-07-30" });
|
||||
expect(ops).toEqual([{ field: "completedDate", value: "2026-07-30" }]);
|
||||
});
|
||||
|
||||
it("maps completedDate returned by a board GET row", () => {
|
||||
const row = mapWorkOrderTableRow({
|
||||
id: 42,
|
||||
completedDate: "2026-07-30T00:00:00Z",
|
||||
});
|
||||
|
||||
expect(row.completedDate).toBe("2026-07-30");
|
||||
});
|
||||
});
|
||||
|
|
@ -3,8 +3,10 @@ import { mapWizardDraftToFormValues } from "@/domain/work-orders/mappers/wizard-
|
|||
import {
|
||||
EMPTY_WIZARD_DRAFT,
|
||||
canContinueWizardStep1,
|
||||
canContinueWizardStep2,
|
||||
canCreateWorkOrderFromWizard,
|
||||
isWizardStepValid,
|
||||
isWizardWoNumberValid,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
describe("mapWizardDraftToFormValues", () => {
|
||||
|
|
@ -63,12 +65,18 @@ describe("mapWizardDraftToFormValues", () => {
|
|||
});
|
||||
|
||||
describe("wizard step validation", () => {
|
||||
it("blocks step 1 continue without type", () => {
|
||||
expect(canContinueWizardStep1(EMPTY_WIZARD_DRAFT)).toBe(false);
|
||||
expect(isWizardStepValid(1, EMPTY_WIZARD_DRAFT)).toBe(false);
|
||||
it("allows step 1 continue without a woNumber for backend SH generation", () => {
|
||||
expect(canContinueWizardStep1(EMPTY_WIZARD_DRAFT)).toBe(true);
|
||||
expect(isWizardStepValid(1, EMPTY_WIZARD_DRAFT)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows step 1 when type is set without woNumber", () => {
|
||||
it("allows step 1 without type when woNumber is set (type defaults to PM)", () => {
|
||||
const draft = { ...EMPTY_WIZARD_DRAFT, woNumber: "12345" };
|
||||
expect(canContinueWizardStep1(draft)).toBe(true);
|
||||
expect(isWizardStepValid(1, draft)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows step 1 when woNumber is missing even with type", () => {
|
||||
const draft = { ...EMPTY_WIZARD_DRAFT, type: "PM" as const };
|
||||
expect(canContinueWizardStep1(draft)).toBe(true);
|
||||
expect(isWizardStepValid(1, draft)).toBe(true);
|
||||
|
|
@ -84,6 +92,51 @@ describe("wizard step validation", () => {
|
|||
expect(isWizardStepValid(1, draft)).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks step 1 for Emergency without severity", () => {
|
||||
const draft = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "Emergency" as const,
|
||||
woNumber: "12345",
|
||||
};
|
||||
expect(canContinueWizardStep1(draft)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows step 1 for Emergency with severity", () => {
|
||||
const draft = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "Emergency" as const,
|
||||
woNumber: "12345",
|
||||
severity: 2 as const,
|
||||
};
|
||||
expect(canContinueWizardStep1(draft)).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks step 2 without POC name/phone", () => {
|
||||
const draft = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM" as const,
|
||||
woNumber: "12345",
|
||||
locationId: "99",
|
||||
siteCode: "BK5",
|
||||
};
|
||||
expect(canContinueWizardStep2(draft)).toBe(false);
|
||||
expect(isWizardStepValid(2, draft)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows step 2 when site and POC are set", () => {
|
||||
const draft = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM" as const,
|
||||
woNumber: "12345",
|
||||
locationId: "99",
|
||||
siteCode: "BK5",
|
||||
pocName: "Jordan",
|
||||
pocPhone: "555-0100",
|
||||
};
|
||||
expect(canContinueWizardStep2(draft)).toBe(true);
|
||||
expect(isWizardStepValid(2, draft)).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks create without siteCode", () => {
|
||||
const draft = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
|
@ -95,44 +148,54 @@ describe("wizard step validation", () => {
|
|||
expect(isWizardStepValid(2, draft)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows create when type and siteCode are set (woNumber optional)", () => {
|
||||
const draft = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM" as const,
|
||||
locationId: "99",
|
||||
siteCode: "BK5",
|
||||
};
|
||||
expect(canCreateWorkOrderFromWizard(draft)).toBe(true);
|
||||
expect(isWizardStepValid(2, draft)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows create when type, woNumber, and siteCode are set", () => {
|
||||
it("allows create when type, woNumber, site, and POC are set", () => {
|
||||
const draft = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM" as const,
|
||||
woNumber: "12345678901234567890",
|
||||
locationId: "99",
|
||||
siteCode: "BK5",
|
||||
pocName: "Jordan",
|
||||
pocPhone: "555-0100",
|
||||
};
|
||||
expect(canCreateWorkOrderFromWizard(draft)).toBe(true);
|
||||
expect(isWizardStepValid(2, draft)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows step 1 when woNumber is empty", () => {
|
||||
it("allows provisional SH-prefixed woNumber", () => {
|
||||
const draft = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
woNumber: "SH12345",
|
||||
};
|
||||
expect(canContinueWizardStep1(draft)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts blank woNumber for backend SH generation", () => {
|
||||
const draft = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM" as const,
|
||||
woNumber: "",
|
||||
};
|
||||
expect(isWizardWoNumberValid(draft.woNumber)).toBe(true);
|
||||
expect(canContinueWizardStep1(draft)).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks step 1 when woNumber is non-numeric", () => {
|
||||
it("accepts whitespace-only woNumber for backend SH generation", () => {
|
||||
const draft = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM" as const,
|
||||
woNumber: "abc",
|
||||
woNumber: " ",
|
||||
};
|
||||
expect(canContinueWizardStep1(draft)).toBe(false);
|
||||
expect(isWizardWoNumberValid(draft.woNumber)).toBe(true);
|
||||
expect(canContinueWizardStep1(draft)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-alphanumeric woNumber", () => {
|
||||
expect(isWizardWoNumberValid("!!!")).toBe(false);
|
||||
expect(canContinueWizardStep1({ ...EMPTY_WIZARD_DRAFT, woNumber: "!!!" })).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts alphanumeric woNumber", () => {
|
||||
expect(isWizardWoNumberValid("ABC123")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -206,6 +206,21 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
|||
expect(payload.scheduledDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits week-mode flags when scheduleWeekOnly is set without a week", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM",
|
||||
siteCode: "BK5",
|
||||
scheduleWeekOnly: true,
|
||||
scheduledOn: "",
|
||||
targetWeek: "",
|
||||
});
|
||||
|
||||
expect(payload.scheduleWeekOnly).toBeUndefined();
|
||||
expect(payload.targetWeek).toBeUndefined();
|
||||
expect(payload.scheduledDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits appointment fields when vendor is missing but still sends techPhone and vendorNotes", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
|
@ -250,14 +265,12 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
|||
expect(payload.workOrderType).toBe(3);
|
||||
});
|
||||
|
||||
it("throws when type is missing", () => {
|
||||
expect(() =>
|
||||
mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
||||
siteCode: "BK5",
|
||||
}),
|
||||
).toThrow("Work order type is required");
|
||||
it("defaults empty type to PM", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
siteCode: "BK5",
|
||||
});
|
||||
expect(payload.workOrderType).toBe(WORK_ORDER_TYPE_API.PM);
|
||||
});
|
||||
|
||||
it("throws when siteCode cannot be resolved", () => {
|
||||
|
|
|
|||
|
|
@ -59,6 +59,12 @@ describe("expandTablePatchToBoardFieldOps", () => {
|
|||
expect(expandTablePatchToBoardFieldOps({ flagColor: null })).toEqual([
|
||||
{ field: "flagColor", value: "" },
|
||||
]);
|
||||
expect(expandTablePatchToBoardFieldOps({ severity: 3 })).toEqual([
|
||||
{ field: "severity", value: "3" },
|
||||
]);
|
||||
expect(expandTablePatchToBoardFieldOps({ severity: null })).toEqual([
|
||||
{ field: "severity", value: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits service notes modal ops in pm → extraServices → serviceNotes order", () => {
|
||||
|
|
|
|||
|
|
@ -147,11 +147,13 @@ describe("mapWorkOrderTableRow (board DTO)", () => {
|
|||
docStatus: 1,
|
||||
dayGroup: "wednesday",
|
||||
flagColor: "#EF4444",
|
||||
severity: 2,
|
||||
});
|
||||
|
||||
expect(row.woNumber).toBe("10000001235");
|
||||
expect(row.site).toBe("BK5");
|
||||
expect(row.type).toBe("Reactive");
|
||||
expect(row.severity).toBe(2);
|
||||
expect(row.status).toBe("Scheduled");
|
||||
expect(row.scheduledOn).toBe("2026-07-15");
|
||||
expect(row.dueDate).toBe("2026-07-18");
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const BASE_ROW = {
|
|||
id: 99,
|
||||
rowVersion: "AAAAAAA=",
|
||||
completedDate: "",
|
||||
severity: null,
|
||||
} as WorkOrderTableRow;
|
||||
|
||||
describe("runWorkOrderTablePatch completedDate", () => {
|
||||
|
|
|
|||
|
|
@ -50,11 +50,12 @@ const BASE_SERVER: ServerState = {
|
|||
docStatus: "No",
|
||||
};
|
||||
|
||||
const BASE_ROW = {
|
||||
const BASE_ROW: WorkOrderTableRow = {
|
||||
id: 42,
|
||||
woNumber: "WO-42",
|
||||
site: "SEA1",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "d1",
|
||||
dispatcherName: "Dispatcher",
|
||||
scheduledOn: "2026-06-01",
|
||||
|
|
@ -93,7 +94,7 @@ const BASE_ROW = {
|
|||
rowVersion: "AAAAAAA=",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
} as WorkOrderTableRow;
|
||||
};
|
||||
|
||||
describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
||||
let serverState: ServerState;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
const rows = [
|
||||
{ id: "wo-1", woNumber: "12345678901" },
|
||||
{ id: "wo-2", woNumber: "99999999999" },
|
||||
] as WorkOrderTableRow[];
|
||||
|
||||
describe("findDuplicateWoNumberRow", () => {
|
||||
it("returns a matching row when the wo number already exists", () => {
|
||||
expect(findDuplicateWoNumberRow("12345678901", rows)).toEqual(rows[0]);
|
||||
});
|
||||
|
||||
it("ignores blank values and excluded ids", () => {
|
||||
expect(findDuplicateWoNumberRow("", rows)).toBeUndefined();
|
||||
expect(findDuplicateWoNumberRow("12345678901", rows, "wo-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
draftHasScheduleDate,
|
||||
getInlineCreateMissing,
|
||||
inlineCreateSaveHint,
|
||||
inlineCreateSiteError,
|
||||
} from "@/domain/work-orders/utils/get-inline-create-missing";
|
||||
|
||||
describe("getInlineCreateMissing", () => {
|
||||
it("returns empty when Site, WO#, and POC are filled even without schedule", () => {
|
||||
expect(
|
||||
getInlineCreateMissing({
|
||||
site: "BK5",
|
||||
locationId: "12",
|
||||
woNumber: "25001",
|
||||
pocName: "Jane",
|
||||
pocPhone: "555-0100",
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts locationId alone as site presence", () => {
|
||||
expect(
|
||||
getInlineCreateMissing({
|
||||
site: "",
|
||||
locationId: 42,
|
||||
woNumber: "25001",
|
||||
pocName: "Jane",
|
||||
pocPhone: "555-0100",
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists missing POC fields without requiring schedule", () => {
|
||||
expect(
|
||||
getInlineCreateMissing({
|
||||
site: "BK5",
|
||||
woNumber: "25001",
|
||||
pocName: "",
|
||||
pocPhone: "",
|
||||
}),
|
||||
).toEqual(["pocName", "pocPhone"]);
|
||||
});
|
||||
|
||||
it("lists site and woNumber when empty", () => {
|
||||
expect(getInlineCreateMissing({})).toEqual(["site", "woNumber", "pocName", "pocPhone"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inlineCreateSaveHint", () => {
|
||||
it("returns Save when ready", () => {
|
||||
expect(inlineCreateSaveHint([])).toBe("Save");
|
||||
});
|
||||
|
||||
it("lists missing labels", () => {
|
||||
expect(inlineCreateSaveHint(["site", "woNumber"])).toBe("Missing: Site, WO #");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inlineCreateSiteError", () => {
|
||||
it("prefers site-required message when site is missing", () => {
|
||||
expect(inlineCreateSiteError(["site", "pocName"])).toBe("Site is required");
|
||||
});
|
||||
|
||||
it("surfaces contact message when only POC is missing", () => {
|
||||
expect(inlineCreateSiteError(["pocName", "pocPhone"])).toBe(
|
||||
"Contact name and phone are required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("draftHasScheduleDate", () => {
|
||||
it("is true for a concrete day", () => {
|
||||
expect(draftHasScheduleDate({ scheduledOn: "2026-06-25" })).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for week-only", () => {
|
||||
expect(
|
||||
draftHasScheduleDate({ scheduledOn: "", scheduleWeekOnly: true, schedMode: "week" }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -12,6 +12,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
woNumber: "1001",
|
||||
site: "SITE-A",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "10",
|
||||
dispatcherName: "Alex",
|
||||
scheduledOn: "2026-07-16",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
|||
woNumber: "1001",
|
||||
site: "SITE-A",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "10",
|
||||
dispatcherName: "Alex",
|
||||
scheduledOn: "2026-07-16",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
woNumber: "12345678901",
|
||||
site: "SITE01",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Alice",
|
||||
scheduledOn: "2026-06-23",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue