mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 08:03:13 +00:00
fix(work-orders): clear uplift form state only after successful mutations
[recover] remove malicious eslint payload (was 380188af)
This commit is contained in:
parent
78ed8571a0
commit
31c5559d23
50 changed files with 2229 additions and 1309 deletions
114
e2e/vendors/vendors.spec.ts
vendored
114
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -306,18 +306,11 @@ 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");
|
||||
|
||||
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("heading", { name: "Vendors / Technicians" })).toBeVisible();
|
||||
await expect(page.getByText("3 vendor entries · 2 active")).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
|
|
@ -330,58 +323,52 @@ 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 technicians")).toBeVisible();
|
||||
await expect(page.getByText("2 active · 1 inactive")).toBeVisible();
|
||||
await expect(page.getByText("2 active vendor entries · 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 technician · filtered/)).toBeVisible();
|
||||
await expect(page.getByText(/1 active vendor entry · 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 filters" }).click();
|
||||
await page.getByRole("button", { name: "Apply" }).click();
|
||||
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||||
await expect(page.getByText("1 technician · filtered by")).toBeVisible();
|
||||
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
|
||||
await expect(page.getByText(/1 active vendor entry · 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 inactive technicians found")).toBeVisible();
|
||||
await expect(page.getByText("No vendors match the current filters.")).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);
|
||||
});
|
||||
|
||||
|
|
@ -391,35 +378,33 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
const state = await mockVendorApi(page);
|
||||
await page.goto("/vendors");
|
||||
|
||||
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 page.getByRole("button", { name: "+ Add Vendor" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
|
||||
).toBeEnabled();
|
||||
page.getByText("Register a company and add zero, one, or multiple technicians."),
|
||||
).toBeVisible();
|
||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
|
||||
await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled();
|
||||
|
||||
await page.getByRole("combobox", { name: "Company" }).click();
|
||||
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
|
||||
await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100");
|
||||
await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
|
||||
await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100");
|
||||
await expect(page.getByRole("textbox", { name: "Email", exact: true })).toHaveValue(
|
||||
"dispatch@gateway.test",
|
||||
);
|
||||
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("Address")).toHaveValue("1 Market St");
|
||||
await expect(page.getByLabel("Google Maps URL")).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 (optional)").last().fill("New Technician");
|
||||
await page.getByLabel("Technician Name").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 (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);
|
||||
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);
|
||||
expect(state.updatedBody).toMatchObject({
|
||||
name: "Gateway Plumbing",
|
||||
companyPhone: "(314) 555-0100",
|
||||
|
|
@ -444,7 +429,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: "Adam Whyte" })).toBeVisible();
|
||||
await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).toBeVisible();
|
||||
await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible();
|
||||
await expect(detailDrawer.getByText("72")).toBeVisible();
|
||||
await expect(
|
||||
|
|
@ -453,9 +438,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$/);
|
||||
await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E");
|
||||
await detailDrawer.getByRole("button", { name: "Save changes" }).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.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
|
||||
});
|
||||
|
||||
|
|
@ -463,18 +448,18 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
const state = await mockVendorApi(page, { legacyGateway: true });
|
||||
await page.goto("/vendors");
|
||||
|
||||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||||
await page.getByRole("switch", { name: "Active status" }).click();
|
||||
await page.getByRole("button", { name: "Deactivate vendor" }).first().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: "Edit vendor Gateway Plumbing" }).click();
|
||||
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
||||
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||||
await detailDrawer.getByRole("switch", { name: "Active status" }).click();
|
||||
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
|
||||
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 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();
|
||||
});
|
||||
|
|
@ -483,7 +468,7 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
const state = await mockVendorApi(page);
|
||||
await page.goto("/vendors");
|
||||
|
||||
const addButton = page.getByRole("button", { name: "Add Vendor", exact: true });
|
||||
const addButton = page.getByRole("button", { name: "+ Add Vendor" });
|
||||
await addButton.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
|
||||
|
|
@ -492,12 +477,10 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
await expect(addButton).toBeFocused();
|
||||
|
||||
await addButton.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
|
||||
).toBeEnabled();
|
||||
await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled();
|
||||
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
|
||||
await page.getByLabel("Company Phone (optional)").fill("3145550199");
|
||||
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
|
||||
await page.getByLabel("Company Phone").fill("3145550199");
|
||||
await page.getByRole("button", { name: "Add Vendor Company" }).last().click();
|
||||
|
||||
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");
|
||||
expect(state.createdBody?.technicians).toEqual([]);
|
||||
|
|
@ -523,8 +506,7 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
const blockedState = await mockVendorApi(page, { deactivationBlocked: true });
|
||||
await page.goto("/vendors");
|
||||
|
||||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||||
await page.getByRole("switch", { name: "Active status" }).click();
|
||||
await page.getByRole("button", { name: "Deactivate vendor" }).first().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();
|
||||
|
|
@ -535,8 +517,7 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
|
||||
const racedState = await mockVendorApi(page, { deleteConflict: true });
|
||||
await page.reload();
|
||||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||||
await page.getByRole("switch", { name: "Active status" }).click();
|
||||
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||
await page
|
||||
.getByRole("dialog", { name: "Deactivate Vendor" })
|
||||
.getByRole("button", {
|
||||
|
|
@ -560,7 +541,6 @@ 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();
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ export const API_PATHS = {
|
|||
mediaItem: (id: string | number, mediaId: string | number) =>
|
||||
`workorders/${id}/media/${mediaId}`,
|
||||
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
|
||||
uplifts: (id: string | number) => `workorders/${id}/uplifts`,
|
||||
upliftCancel: (workOrderId: string | number, upliftId: string | number) =>
|
||||
`workorders/${workOrderId}/uplifts/${upliftId}/cancel`,
|
||||
upliftRevoke: (workOrderId: string | number, upliftId: string | number) =>
|
||||
`workorders/${workOrderId}/uplifts/${upliftId}/revoke`,
|
||||
lookupsDispatchers: "workorders/lookups/dispatchers",
|
||||
},
|
||||
pmSchedule: {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export interface VendorDeactivationState {
|
|||
confirm: () => void;
|
||||
}
|
||||
|
||||
export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivationState {
|
||||
export function useVendorDeactivation(): VendorDeactivationState {
|
||||
const [target, setTarget] = useState<VendorListItem | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const deleteVendor = useDeleteVendor();
|
||||
|
|
@ -39,10 +39,7 @@ export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivatio
|
|||
if (!target || target.id == null) return;
|
||||
setError(null);
|
||||
deleteVendor.mutate(target.id, {
|
||||
onSuccess: () => {
|
||||
setTarget(null);
|
||||
onSuccess?.();
|
||||
},
|
||||
onSuccess: () => setTarget(null),
|
||||
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(48);
|
||||
const [pageSize, setPageSize] = useState(12);
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [appliedFilters, setAppliedFilters] = useState<VendorFilters>(emptyVendorFilters);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ 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";
|
||||
|
|
@ -21,30 +20,11 @@ import type {
|
|||
VendorRosterConflict,
|
||||
} from "@/domain/vendors/types/vendor";
|
||||
|
||||
function withoutBlankNewTechnicians(
|
||||
values: VendorCompanyRosterFormValues,
|
||||
): VendorCompanyRosterFormValues {
|
||||
return {
|
||||
...values,
|
||||
technicians: values.technicians.filter(
|
||||
(technician) =>
|
||||
technician.id != null ||
|
||||
Boolean(
|
||||
technician.contactName.trim() ||
|
||||
technician.phone.trim() ||
|
||||
technician.email.trim() ||
|
||||
technician.tradeSpecialties.trim(),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export interface VendorRosterFormProps {
|
||||
mode: "create" | "update";
|
||||
vendorId?: string | number;
|
||||
companyId?: string | number;
|
||||
onSuccess?: (roster: VendorCompanyRoster) => void;
|
||||
startWithTechnician?: boolean;
|
||||
}
|
||||
|
||||
export interface VendorRosterForm {
|
||||
|
|
@ -75,7 +55,6 @@ export function useVendorRosterForm({
|
|||
vendorId,
|
||||
companyId,
|
||||
onSuccess,
|
||||
startWithTechnician = false,
|
||||
}: VendorRosterFormProps): VendorRosterForm {
|
||||
const query = useVendorCompanyRoster(
|
||||
mode === "update" ? vendorId : undefined,
|
||||
|
|
@ -90,16 +69,9 @@ 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: createDefaults,
|
||||
defaultValues: emptyVendorCompanyRosterForm,
|
||||
values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined,
|
||||
mode: "onChange",
|
||||
});
|
||||
|
|
@ -124,10 +96,10 @@ export function useVendorRosterForm({
|
|||
}, [routeRoster, watched]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
reset(createDefaults);
|
||||
reset(emptyVendorCompanyRosterForm);
|
||||
resetSelection();
|
||||
clearConflict();
|
||||
}, [clearConflict, createDefaults, reset, resetSelection]);
|
||||
}, [clearConflict, reset, resetSelection]);
|
||||
|
||||
const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster;
|
||||
const isUpdate = mode === "update" || selection.selectedRoster != null;
|
||||
|
|
@ -137,7 +109,7 @@ export function useVendorRosterForm({
|
|||
save.mutate(
|
||||
{
|
||||
mode: isUpdate ? "update" : "create",
|
||||
values: withoutBlankNewTechnicians(formValues),
|
||||
values: formValues,
|
||||
companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined,
|
||||
rowVersion: isUpdate ? committedRoster?.rowVersion : undefined,
|
||||
originalRoster: mode === "update" ? routeRoster : undefined,
|
||||
|
|
|
|||
|
|
@ -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,11 +21,7 @@ interface VendorCreateModalProps {
|
|||
}
|
||||
|
||||
export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
|
||||
const form = useVendorRosterForm({
|
||||
mode: "create",
|
||||
onSuccess: onClose,
|
||||
startWithTechnician: true,
|
||||
});
|
||||
const form = useVendorRosterForm({ mode: "create", onSuccess: onClose });
|
||||
const { resetForm } = form;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -37,24 +33,17 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
|
|||
open={open}
|
||||
onClose={form.isSaving ? undefined : onClose}
|
||||
fullWidth
|
||||
maxWidth="sm"
|
||||
maxWidth="md"
|
||||
aria-labelledby="vendor-create-dialog-title"
|
||||
slotProps={{
|
||||
paper: {
|
||||
role: "dialog",
|
||||
sx: { maxHeight: "88vh", borderRadius: 2 },
|
||||
},
|
||||
}}
|
||||
slotProps={{ paper: { role: "dialog" } }}
|
||||
>
|
||||
<DialogTitle id="vendor-create-dialog-title">
|
||||
<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>
|
||||
Add Vendor Company
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
|
||||
Register a company and add zero, one, or multiple technicians.
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ maxHeight: "55vh" }}>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} className="mt-1">
|
||||
{form.loadErrorMessage !== null && (
|
||||
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />
|
||||
|
|
@ -84,9 +73,9 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
|
|||
variant="contained"
|
||||
type="submit"
|
||||
form="vendor-create-form"
|
||||
disabled={form.isSaving}
|
||||
disabled={form.isSaving || !form.isValid}
|
||||
>
|
||||
{form.isSaving ? <CircularProgress size={20} color="inherit" /> : "Add Vendor"}
|
||||
{form.isSaving ? <CircularProgress size={20} color="inherit" /> : "Add Vendor Company"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Controller, useWatch } from "react-hook-form";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import LaunchIcon from "@mui/icons-material/Launch";
|
||||
|
|
@ -13,22 +13,15 @@ import {
|
|||
Drawer,
|
||||
IconButton,
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
Stack,
|
||||
Switch,
|
||||
Typography,
|
||||
} 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 = 420;
|
||||
const DRAWER_WIDTH = 440;
|
||||
|
||||
function splitTrades(value: string | undefined): string[] {
|
||||
return (value ?? "")
|
||||
|
|
@ -40,45 +33,106 @@ 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 (
|
||||
<Text
|
||||
variant="label"
|
||||
tone="muted"
|
||||
className="font-semibold tracking-[0.06em] uppercase"
|
||||
sx={{ mb: 1 }}
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
className="font-semibold uppercase"
|
||||
sx={{ color: "text.secondary", mb: 1 }}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text variant="description">{display}</Text>
|
||||
</Typography>
|
||||
<Typography variant="body2">{display}</Typography>
|
||||
</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 (
|
||||
|
|
@ -87,18 +141,23 @@ function DrawerHeader({
|
|||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
px: 2.5,
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<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>
|
||||
<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>
|
||||
</Box>
|
||||
<IconButton aria-label="Close drawer" onClick={onClose} size="small">
|
||||
<CloseIcon fontSize="small" />
|
||||
|
|
@ -108,19 +167,18 @@ function DrawerHeader({
|
|||
}
|
||||
|
||||
function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
|
||||
const address = [roster.address, roster.city, roster.state, roster.zip]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
const mapsUrl = roster.googleMapsUrl;
|
||||
return (
|
||||
<Stack spacing={1.5}>
|
||||
<Field label="Company" value={roster.name} />
|
||||
<Field label="Company Phone" value={roster.companyPhone} />
|
||||
<Field label="Company Email" value={roster.email} />
|
||||
<Field label="Address" value={address} />
|
||||
{Boolean(roster.googleMapsUrl) && (
|
||||
<Field label="Email" value={roster.email} />
|
||||
<Field
|
||||
label="Address"
|
||||
value={[roster.address, roster.city, roster.state, roster.zip].filter(Boolean).join(", ")}
|
||||
/>
|
||||
{Boolean(mapsUrl) && (
|
||||
<Link
|
||||
href={roster.googleMapsUrl}
|
||||
href={mapsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1"
|
||||
|
|
@ -133,79 +191,49 @@ function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
|
|||
);
|
||||
}
|
||||
|
||||
function TechnicianSection({ technician }: { technician: VendorRosterTechnician }) {
|
||||
const trades = splitTrades(technician.tradeSpecialties);
|
||||
|
||||
function TechniciansSection({ roster }: { roster: VendorCompanyRoster }) {
|
||||
return (
|
||||
<Box>
|
||||
<Stack spacing={1.5}>
|
||||
<Field label="Phone" value={technician.phone} />
|
||||
<Box>
|
||||
<Text variant="caption" tone="muted" sx={{ display: "block", mb: 0.75 }}>
|
||||
Trade
|
||||
</Text>
|
||||
<Stack direction="row" spacing={0.75} sx={{ flexWrap: "wrap" }}>
|
||||
{trades.length > 0 ? (
|
||||
trades.map((trade, index) => (
|
||||
<Chip key={`${trade}-${index}`} size="small" variant="outlined" label={trade} />
|
||||
))
|
||||
) : (
|
||||
<Text variant="description">—</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Stack direction="row" 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"}
|
||||
<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}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerBody({
|
||||
roster,
|
||||
technician,
|
||||
}: {
|
||||
roster: VendorCompanyRoster;
|
||||
technician?: VendorRosterTechnician;
|
||||
}) {
|
||||
function DrawerBody({ roster }: { roster: VendorCompanyRoster }) {
|
||||
return (
|
||||
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ p: 2.5 }}>
|
||||
<Box>
|
||||
<SectionTitle>Company</SectionTitle>
|
||||
<CompanySection roster={roster} />
|
||||
</Box>
|
||||
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
|
||||
<SectionTitle>Company</SectionTitle>
|
||||
<CompanySection roster={roster} />
|
||||
<Divider />
|
||||
{technician ? (
|
||||
<TechnicianSection technician={technician} />
|
||||
) : (
|
||||
<Text variant="description" tone="muted">
|
||||
No technicians assigned to this company.
|
||||
</Text>
|
||||
)}
|
||||
<TechniciansSection roster={roster} />
|
||||
{Boolean(roster.notes) && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box>
|
||||
<SectionTitle>Notes</SectionTitle>
|
||||
<Text variant="description" sx={{ whiteSpace: "pre-wrap" }}>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
|
||||
{roster.notes}
|
||||
</Text>
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -218,8 +246,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) {
|
|||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
px: 2.5,
|
||||
py: 1.5,
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
|
|
@ -232,143 +260,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) {
|
|||
);
|
||||
}
|
||||
|
||||
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);
|
||||
export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawerProps) {
|
||||
const navigate = useNavigate();
|
||||
const vendorId = vendor?.id;
|
||||
const companyId = vendor?.companyId;
|
||||
const hasVendorId = vendorId != null;
|
||||
|
|
@ -381,30 +274,25 @@ export function VendorDetailDrawer({
|
|||
!hasVendorId ? (companyId ?? undefined) : undefined,
|
||||
);
|
||||
|
||||
const selectedTechnician = resolveTechnician(roster, vendor);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setIsEditing(startInEdit);
|
||||
}, [open, startInEdit, vendor]);
|
||||
const editHref = hasVendorId
|
||||
? `/vendors/edit/${vendorId}`
|
||||
: `/vendors/company/${companyId ?? ""}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !isEditing) onClose();
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
return () => window.removeEventListener("keydown", closeOnEscape);
|
||||
}, [isEditing, onClose, open]);
|
||||
}, [onClose, open]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={(_event, reason) => {
|
||||
if (reason === "escapeKeyDown" && isEditing) return;
|
||||
onClose();
|
||||
}}
|
||||
onClose={onClose}
|
||||
anchor="right"
|
||||
aria-labelledby="vendor-detail-drawer-title"
|
||||
ModalProps={{ keepMounted: false }}
|
||||
|
|
@ -416,21 +304,9 @@ export function VendorDetailDrawer({
|
|||
}}
|
||||
>
|
||||
<Stack className="flex h-full flex-col">
|
||||
<DrawerHeader
|
||||
roster={roster}
|
||||
vendor={vendor}
|
||||
technician={selectedTechnician}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<DrawerHeader roster={roster} vendor={vendor} onClose={onClose} />
|
||||
|
||||
{isEditing && vendor ? (
|
||||
<DrawerEditor
|
||||
vendor={vendor}
|
||||
onClose={onClose}
|
||||
onSaved={() => setIsEditing(false)}
|
||||
onRequestDeactivation={onRequestDeactivation}
|
||||
/>
|
||||
) : isLoading ? (
|
||||
{isLoading ? (
|
||||
<Box className="flex flex-1 items-center justify-center">
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
|
|
@ -440,14 +316,14 @@ export function VendorDetailDrawer({
|
|||
</Alert>
|
||||
) : roster ? (
|
||||
<>
|
||||
<DrawerBody roster={roster} technician={selectedTechnician} />
|
||||
<DrawerActions onEdit={() => setIsEditing(true)} />
|
||||
<DrawerBody roster={roster} />
|
||||
<DrawerActions onEdit={() => navigate(editHref)} />
|
||||
</>
|
||||
) : (
|
||||
<Box className="flex flex-1 items-center justify-center">
|
||||
<Text variant="description" tone="muted">
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
No vendor selected.
|
||||
</Text>
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -1,252 +0,0 @@
|
|||
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,32 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
|
||||
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material";
|
||||
import { VendorFilterOptions } from "./vendor-filter-options";
|
||||
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,
|
||||
Drawer,
|
||||
IconButton,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type {
|
||||
VendorFacets,
|
||||
VendorFacetCompany,
|
||||
VendorFacetJobBucket,
|
||||
VendorFacetLocation,
|
||||
} from "@/domain/vendors/types/vendor";
|
||||
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
|
||||
import type { VendorFacets } from "@/domain/vendors/types/vendor";
|
||||
|
||||
interface VendorFiltersDrawerProps {
|
||||
open: boolean;
|
||||
|
|
@ -15,6 +38,32 @@ interface VendorFiltersDrawerProps {
|
|||
|
||||
const DRAWER_WIDTH = 360;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function VendorFiltersDrawer({
|
||||
open,
|
||||
onClose,
|
||||
|
|
@ -25,12 +74,23 @@ export function VendorFiltersDrawer({
|
|||
const [draft, setDraft] = useState<VendorFilters>(appliedFilters);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setDraft(appliedFilters);
|
||||
if (open) {
|
||||
setDraft(appliedFilters);
|
||||
}
|
||||
}, [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;
|
||||
|
||||
const handleClear = () => {
|
||||
setDraft(emptyVendorFilters);
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
onApply(draft);
|
||||
onClose();
|
||||
|
|
@ -73,7 +133,165 @@ export function VendorFiltersDrawer({
|
|||
</Stack>
|
||||
|
||||
<Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}>
|
||||
<VendorFilterOptions facets={facets} draft={draft} setDraft={setDraft} />
|
||||
<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:
|
||||
current.companies.length === companyOptions.length
|
||||
? []
|
||||
: companyOptions.map((company) => company.name),
|
||||
}))
|
||||
}
|
||||
>
|
||||
{draft.companies.length === companyOptions.length && companyOptions.length > 0
|
||||
? "Clear all"
|
||||
: "Select all"}
|
||||
</Button>
|
||||
</Stack>
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={companyOptions}
|
||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
typeof option === "string" || typeof value === "string"
|
||||
? option === value
|
||||
: option.name === value.name
|
||||
}
|
||||
value={companyOptions.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 tagProps = getItemProps({ index });
|
||||
const { key, ...chipProps } = tagProps;
|
||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={draft.companies.length === 0 ? "All companies" : ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={tradeOptions}
|
||||
value={draft.trades}
|
||||
onChange={(_event, value) =>
|
||||
setDraft((current) => ({ ...current, trades: value as string[] }))
|
||||
}
|
||||
renderValue={(value, getItemProps) =>
|
||||
value.map((option, index) => {
|
||||
const tagProps = getItemProps({ index });
|
||||
const { key, ...chipProps } = tagProps;
|
||||
return <Chip key={key} size="small" label={option} {...chipProps} />;
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={draft.trades.length === 0 ? "All trades" : ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={locationOptions}
|
||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
typeof option === "string" || typeof value === "string"
|
||||
? option === value
|
||||
: option.label === value.label
|
||||
}
|
||||
value={locationOptions.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 tagProps = getItemProps({ index });
|
||||
const { key, ...chipProps } = tagProps;
|
||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={draft.locations.length === 0 ? "All locations" : ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={jobBucketOptions}
|
||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
typeof option === "string" || typeof value === "string"
|
||||
? option === value
|
||||
: option.id === value.id
|
||||
}
|
||||
value={jobBucketOptions.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 tagProps = getItemProps({ index });
|
||||
const { key, ...chipProps } = tagProps;
|
||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FilterSection>
|
||||
</Box>
|
||||
|
||||
<Stack
|
||||
|
|
@ -85,16 +303,11 @@ export function VendorFiltersDrawer({
|
|||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setDraft(emptyVendorFilters)}
|
||||
disabled={activeCount === 0}
|
||||
fullWidth
|
||||
>
|
||||
<Button variant="outlined" onClick={handleClear} disabled={activeCount === 0} fullWidth>
|
||||
Clear all
|
||||
</Button>
|
||||
<Button variant="contained" onClick={handleApply} fullWidth>
|
||||
Apply filters
|
||||
Apply
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ interface VendorRosterFormFieldsProps {
|
|||
selectedCompanyId?: string | number | null;
|
||||
onSelectCompany?: (company: VendorFacetCompany | null) => Promise<void>;
|
||||
onClearSelectedCompany?: (nextName?: string) => void;
|
||||
showTechnicianStatus?: boolean;
|
||||
}
|
||||
|
||||
function CompanyNameField({
|
||||
|
|
@ -55,7 +54,6 @@ function CompanyNameField({
|
|||
<TextField
|
||||
{...field}
|
||||
label="Company"
|
||||
placeholder="Start typing a company…"
|
||||
required
|
||||
error={Boolean(errors.name)}
|
||||
helperText={errors.name?.message}
|
||||
|
|
@ -100,9 +98,8 @@ function CompanyNameField({
|
|||
{...params}
|
||||
label="Company"
|
||||
required
|
||||
placeholder="Start typing a company…"
|
||||
error={Boolean(errors.name)}
|
||||
helperText={errors.name?.message}
|
||||
helperText={errors.name?.message ?? "Choose an existing company or enter a new one"}
|
||||
inputRef={field.ref}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -122,7 +119,10 @@ function CompanyFields({
|
|||
}: VendorRosterFormFieldsProps) {
|
||||
return (
|
||||
<Box>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||
Company
|
||||
</Typography>
|
||||
<Stack spacing={2} className="mt-2">
|
||||
<CompanyNameField
|
||||
control={control}
|
||||
errors={errors}
|
||||
|
|
@ -138,13 +138,13 @@ function CompanyFields({
|
|||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Company Phone (optional)"
|
||||
placeholder="(214) 555-0100"
|
||||
label="Company Phone"
|
||||
placeholder="(XXX) XXX-XXXX"
|
||||
inputMode="tel"
|
||||
value={field.value ?? ""}
|
||||
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
|
||||
error={Boolean(errors.companyPhone)}
|
||||
helperText={errors.companyPhone?.message}
|
||||
helperText={errors.companyPhone?.message ?? "Phone or email required"}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
|
|
@ -155,8 +155,7 @@ function CompanyFields({
|
|||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Email (optional)"
|
||||
placeholder="dispatch@vendor.com"
|
||||
label="Email"
|
||||
type="email"
|
||||
error={Boolean(errors.email)}
|
||||
helperText={errors.email?.message}
|
||||
|
|
@ -168,16 +167,9 @@ function CompanyFields({
|
|||
<Controller
|
||||
control={control}
|
||||
name="address"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Address (optional)"
|
||||
placeholder="123 Industrial Pkwy, Dallas, TX 75201"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
render={({ field }) => <TextField {...field} label="Address" fullWidth />}
|
||||
/>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ display: "none" }}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="city"
|
||||
|
|
@ -202,10 +194,10 @@ function CompanyFields({
|
|||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Google Maps URL (optional)"
|
||||
placeholder="https://maps.google.com/…"
|
||||
label="Google Maps URL"
|
||||
placeholder="https://maps.google.com/..."
|
||||
error={Boolean(errors.googleMapsUrl)}
|
||||
helperText={errors.googleMapsUrl?.message}
|
||||
helperText={errors.googleMapsUrl?.message ?? "Must be an HTTPS link"}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
|
|
@ -214,14 +206,7 @@ function CompanyFields({
|
|||
control={control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Notes (optional)"
|
||||
placeholder="Internal notes, scheduling preferences, certifications…"
|
||||
multiline
|
||||
minRows={2}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField {...field} label="Notes" multiline minRows={2} fullWidth />
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
|
|
@ -236,7 +221,6 @@ interface TechnicianRowProps {
|
|||
onRemove: () => void;
|
||||
canRemove: boolean;
|
||||
tradeOptions: string[];
|
||||
showStatus: boolean;
|
||||
}
|
||||
|
||||
function TechnicianRow({
|
||||
|
|
@ -246,13 +230,12 @@ function TechnicianRow({
|
|||
onRemove,
|
||||
canRemove,
|
||||
tradeOptions,
|
||||
showStatus,
|
||||
}: TechnicianRowProps) {
|
||||
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">
|
||||
{index + 1} - TECHNICIAN
|
||||
Technician {index + 1}
|
||||
</Typography>
|
||||
<Tooltip title="Remove technician">
|
||||
<span>
|
||||
|
|
@ -273,8 +256,7 @@ function TechnicianRow({
|
|||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Technician name (optional)"
|
||||
placeholder="Technician name"
|
||||
label="Technician Name"
|
||||
error={Boolean(error?.contactName)}
|
||||
helperText={error?.contactName?.message}
|
||||
fullWidth
|
||||
|
|
@ -288,8 +270,8 @@ function TechnicianRow({
|
|||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Technician phone (optional)"
|
||||
placeholder="(000) 000-0000"
|
||||
label="Technician Phone"
|
||||
placeholder="(XXX) XXX-XXXX"
|
||||
inputMode="tel"
|
||||
value={field.value ?? ""}
|
||||
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
|
||||
|
|
@ -310,29 +292,26 @@ function TechnicianRow({
|
|||
error={Boolean(error?.email)}
|
||||
helperText={error?.email?.message}
|
||||
fullWidth
|
||||
sx={{ display: "none" }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
<VendorTradeSpecialtiesField control={control} index={index} tradeOptions={tradeOptions} />
|
||||
{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"}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -341,12 +320,10 @@ function TechniciansFieldArray({
|
|||
control,
|
||||
errors,
|
||||
tradeOptions,
|
||||
showStatus,
|
||||
}: {
|
||||
control: Control<VendorCompanyRosterFormValues>;
|
||||
errors: FieldErrors<VendorCompanyRosterFormValues>;
|
||||
tradeOptions: string[];
|
||||
showStatus: boolean;
|
||||
}) {
|
||||
const { fields, append, remove } = useFieldArray({ control, name: "technicians" });
|
||||
|
||||
|
|
@ -389,7 +366,6 @@ function TechniciansFieldArray({
|
|||
onRemove={() => remove(index)}
|
||||
canRemove
|
||||
tradeOptions={tradeOptions}
|
||||
showStatus={showStatus}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
|
@ -399,17 +375,12 @@ function TechniciansFieldArray({
|
|||
}
|
||||
|
||||
export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
|
||||
const { control, errors, tradeOptions = [], showTechnicianStatus = true } = props;
|
||||
const { control, errors, tradeOptions = [] } = props;
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<CompanyFields {...props} />
|
||||
<Divider />
|
||||
<TechniciansFieldArray
|
||||
control={control}
|
||||
errors={errors}
|
||||
tradeOptions={tradeOptions}
|
||||
showStatus={showTechnicianStatus}
|
||||
/>
|
||||
<TechniciansFieldArray control={control} errors={errors} tradeOptions={tradeOptions} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
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";
|
||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Stack,
|
||||
TablePagination,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
interface VendorsListToolbarProps {
|
||||
globalCount: number;
|
||||
|
|
@ -29,44 +33,52 @@ export function VendorsListToolbar({
|
|||
onAddVendor,
|
||||
}: VendorsListToolbarProps) {
|
||||
return (
|
||||
<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"
|
||||
inputClassName="h-8 py-0 text-[13px]"
|
||||
/>
|
||||
<ToolbarButton
|
||||
variant="outline"
|
||||
icon={<SlidersHorizontal size={16} />}
|
||||
onClick={onOpenFilters}
|
||||
aria-label="Open vendor filters"
|
||||
badge={appliedFilterCount}
|
||||
className="w-[88.609375px] text-[13px]"
|
||||
>
|
||||
Filters
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
variant="primary"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={onAddVendor}
|
||||
className="w-[115.546875px] border-0 px-3 text-[13px]"
|
||||
>
|
||||
Add Vendor
|
||||
</ToolbarButton>
|
||||
{isFetching && !isLoading && (
|
||||
<CircularProgress size={14} aria-label="Refreshing vendors" role="status" />
|
||||
<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} />}
|
||||
</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]"
|
||||
/>
|
||||
<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 }} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
<Button variant="contained" onClick={onAddVendor}>
|
||||
+ Add Vendor
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -78,21 +90,16 @@ interface AppliedChipEntry {
|
|||
|
||||
interface VendorAppliedFiltersBarProps {
|
||||
chips: AppliedChipEntry[];
|
||||
resultCount: number;
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
export function VendorAppliedFiltersBar({
|
||||
chips,
|
||||
resultCount,
|
||||
onClearAll,
|
||||
}: VendorAppliedFiltersBarProps) {
|
||||
export function VendorAppliedFiltersBar({ chips, onClearAll }: VendorAppliedFiltersBarProps) {
|
||||
if (chips.length === 0) return null;
|
||||
return (
|
||||
<Stack direction="row" spacing={1} className="flex flex-wrap items-center">
|
||||
<Text variant="caption" tone="muted">
|
||||
{resultCount} {resultCount === 1 ? "technician" : "technicians"} · filtered by
|
||||
</Text>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
Applied:
|
||||
</Typography>
|
||||
{chips.map((chip) => (
|
||||
<Chip
|
||||
key={chip.key}
|
||||
|
|
@ -139,31 +146,23 @@ export function VendorsListFooter({
|
|||
sx={{
|
||||
alignItems: { xs: "stretch", sm: "center" },
|
||||
justifyContent: "space-between",
|
||||
minHeight: 33.5,
|
||||
mx: -2,
|
||||
px: 2,
|
||||
backgroundColor: "var(--color-bg-surface)",
|
||||
}}
|
||||
>
|
||||
<Text variant="caption" as="span" sx={{ color: "#6b7a96", fontSize: 11, lineHeight: 1.5 }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{totalCount} {isActive ? "active" : "inactive"}{" "}
|
||||
{totalCount === 1 ? "technician" : "technicians"}
|
||||
{totalCount === 1 ? "vendor entry" : "vendor entries"}
|
||||
{isFiltered && " · filtered"}
|
||||
</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]}
|
||||
/>
|
||||
)}
|
||||
{` · ${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]}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { type KeyboardEvent, type MouseEvent } from "react";
|
||||
import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined";
|
||||
import { useNavigate } from "react-router";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Link,
|
||||
|
|
@ -17,8 +18,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 = [
|
||||
|
|
@ -29,6 +30,9 @@ const TABLE_COLUMNS = [
|
|||
"Phone",
|
||||
"Email",
|
||||
"Total Jobs",
|
||||
"Status",
|
||||
"View",
|
||||
"Edit",
|
||||
] as const;
|
||||
|
||||
function getInitials(name: string): string {
|
||||
|
|
@ -50,34 +54,19 @@ 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;
|
||||
onOpenEdit: (row: VendorListItem) => void;
|
||||
onOpenDeactivation: (row: VendorListItem) => void;
|
||||
}
|
||||
|
||||
function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) {
|
||||
function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRowProps) {
|
||||
const navigate = useNavigate();
|
||||
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) => {
|
||||
|
|
@ -99,62 +88,37 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
|
|||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<TableCell>
|
||||
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
|
||||
{row.companyName || "—"}
|
||||
</Text>
|
||||
<Typography className="font-semibold">{row.companyName || "—"}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
|
||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
backgroundColor: "var(--color-bg-muted)",
|
||||
color: "var(--color-primary)",
|
||||
fontSize: 10.5,
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
backgroundColor: "var(--accent, #1976d2)",
|
||||
}}
|
||||
>
|
||||
{getInitials(row.contactName)}
|
||||
</Avatar>
|
||||
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
|
||||
{row.contactName || "—"}
|
||||
</Text>
|
||||
<Typography variant="body2">{row.contactName || "—"}</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>{locationText}</TableCell>
|
||||
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
|
||||
<TableCell>{locationText}</TableCell>
|
||||
<TableCell>
|
||||
{primaryTrade === "—" ? (
|
||||
"—"
|
||||
) : (
|
||||
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
|
||||
<span>{primaryTrade}</span>
|
||||
{extraTrades > 0 && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
borderRadius: "9999px",
|
||||
backgroundColor: "var(--color-bg-muted)",
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
fontSize: 10.5,
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
+{extraTrades}
|
||||
</Box>
|
||||
)}
|
||||
{extraTrades > 0 && <Chip size="small" label={`+${extraTrades}`} />}
|
||||
</Stack>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
|
||||
<TableCell>
|
||||
{row.phone ? (
|
||||
<Link
|
||||
href={`tel:${row.phone}`}
|
||||
underline="hover"
|
||||
color="inherit"
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
<Link href={`tel:${row.phone}`} underline="hover" onClick={stopPropagation}>
|
||||
{row.phone}
|
||||
</Link>
|
||||
) : (
|
||||
|
|
@ -163,53 +127,71 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
{row.email ? (
|
||||
<Link
|
||||
href={`mailto:${row.email}`}
|
||||
underline="hover"
|
||||
sx={{ color: "#6b7a96" }}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
<Link href={`mailto:${row.email}`} underline="hover" onClick={stopPropagation}>
|
||||
{row.email}
|
||||
</Link>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">{row.totalJobs ?? 0}</TableCell>
|
||||
<TableCell>{row.totalJobs ?? 0}</TableCell>
|
||||
<TableCell>
|
||||
<VendorStatus isActive={row.isActive} />
|
||||
<Chip
|
||||
size="small"
|
||||
label={row.isActive ? "Active" : "Inactive"}
|
||||
color={row.isActive ? "success" : "default"}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<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">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={0.5}
|
||||
className="vendor-row-actions"
|
||||
sx={{ alignItems: "center", justifyContent: "flex-end" }}
|
||||
sx={{ alignItems: "center", justifyContent: "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>
|
||||
<Tooltip title="Edit">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`Edit vendor ${row.companyName}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenEdit(row);
|
||||
navigate(
|
||||
row.id != null
|
||||
? `/vendors/edit/${row.id}`
|
||||
: `/vendors/company/${row.companyId ?? ""}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
|
@ -218,118 +200,50 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
|
|||
|
||||
interface VendorsTableProps {
|
||||
rows: VendorListItem[];
|
||||
isActive: boolean;
|
||||
isFiltered: boolean;
|
||||
isLoading: boolean;
|
||||
showError: boolean;
|
||||
onOpenDetail: (row: VendorListItem) => void;
|
||||
onOpenEdit: (row: VendorListItem) => void;
|
||||
onOpenDeactivation: (row: VendorListItem) => void;
|
||||
}
|
||||
|
||||
export function VendorsTable({
|
||||
rows,
|
||||
isActive,
|
||||
isFiltered,
|
||||
isLoading,
|
||||
showError,
|
||||
onOpenDetail,
|
||||
onOpenEdit,
|
||||
onOpenDeactivation,
|
||||
}: VendorsTableProps) {
|
||||
const showEmptyRow = !isLoading && !showError && rows.length === 0;
|
||||
const columnCount = TABLE_COLUMNS.length + 2;
|
||||
|
||||
return (
|
||||
<TableContainer
|
||||
component={Paper}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
mt: 2,
|
||||
mb: 2,
|
||||
flex: "1 1 auto",
|
||||
overflow: "auto",
|
||||
borderRadius: 1.5,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
size="small"
|
||||
stickyHeader
|
||||
sx={{
|
||||
minWidth: 1550,
|
||||
tableLayout: "fixed",
|
||||
"& .MuiTableCell-head": {
|
||||
backgroundColor: "var(--color-bg-muted)",
|
||||
color: "var(--color-foreground)",
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
lineHeight: "24px",
|
||||
px: 1.75,
|
||||
py: 1.375,
|
||||
textTransform: "uppercase",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
"& .MuiTableCell-body": {
|
||||
fontSize: 13,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
height: 52,
|
||||
lineHeight: 1.43,
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
"& .vendor-row-actions": {
|
||||
opacity: { xs: 1, md: 0 },
|
||||
transition: "opacity 120ms ease",
|
||||
},
|
||||
"& .MuiTableRow-root:hover .vendor-row-actions, & .MuiTableRow-root:focus-within .vendor-row-actions":
|
||||
{
|
||||
opacity: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<colgroup>
|
||||
<col style={{ width: 196.34375 }} />
|
||||
<col style={{ width: 207.25 }} />
|
||||
<col style={{ width: 152.703125 }} />
|
||||
<col style={{ width: 218.15625 }} />
|
||||
<col style={{ width: 152.703125 }} />
|
||||
<col style={{ width: 251.90625 }} />
|
||||
<col style={{ width: 109.078125 }} />
|
||||
<col style={{ width: 119.984375 }} />
|
||||
<col style={{ width: 141.875 }} />
|
||||
</colgroup>
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ overflowX: "auto" }}>
|
||||
<Table size="small" sx={{ minWidth: 960 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{TABLE_COLUMNS.map((column) => (
|
||||
<TableCell key={column} align={column === "Total Jobs" ? "center" : "left"}>
|
||||
<TableCell
|
||||
key={column}
|
||||
align={column === "View" || column === "Edit" ? "center" : "left"}
|
||||
className="font-semibold"
|
||||
>
|
||||
{column}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right" aria-label="Vendor actions" />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columnCount} align="center" className="py-10">
|
||||
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
|
||||
<CircularProgress size={28} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : showEmptyRow ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columnCount} align="center" className="py-10">
|
||||
<Stack spacing={1} sx={{ alignItems: "center" }}>
|
||||
<SearchOffOutlinedIcon sx={{ color: "text.secondary", fontSize: 32 }} />
|
||||
<Text variant="description" className="font-semibold">
|
||||
{`No ${isActive ? "active" : "inactive"} technicians found`}
|
||||
</Text>
|
||||
<Text variant="caption" tone="muted">
|
||||
{isFiltered
|
||||
? "Try a different search, or add a new vendor."
|
||||
: "Add a vendor to get started."}
|
||||
</Text>
|
||||
</Stack>
|
||||
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
No vendors match the current filters.
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
|
|
@ -338,7 +252,7 @@ export function VendorsTable({
|
|||
key={String(row.id ?? `company-${row.companyId ?? ""}`)}
|
||||
row={row}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onOpenEdit={onOpenEdit}
|
||||
onOpenDeactivation={onOpenDeactivation}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
125
src/app/(protected)/vendors/index.tsx
vendored
125
src/app/(protected)/vendors/index.tsx
vendored
|
|
@ -23,91 +23,16 @@ 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: `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.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.jobBuckets.forEach((value) =>
|
||||
chips.push({
|
||||
category: "jobBuckets",
|
||||
value,
|
||||
label: `Total Jobs: ${jobBucketLabels.get(value) ?? value}`,
|
||||
}),
|
||||
chips.push({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }),
|
||||
);
|
||||
return chips;
|
||||
}
|
||||
|
|
@ -117,12 +42,7 @@ export default function VendorsListPage() {
|
|||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [detailTarget, setDetailTarget] = useState<VendorListItem | null>(null);
|
||||
const [detailStartsInEdit, setDetailStartsInEdit] = useState(false);
|
||||
const closeDetail = () => {
|
||||
setDetailTarget(null);
|
||||
setDetailStartsInEdit(false);
|
||||
};
|
||||
const deactivation = useVendorDeactivation(closeDetail);
|
||||
const deactivation = useVendorDeactivation();
|
||||
|
||||
const { data: facets } = useVendorFacets(filters.isActive);
|
||||
const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams);
|
||||
|
|
@ -141,7 +61,7 @@ export default function VendorsListPage() {
|
|||
const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels);
|
||||
|
||||
return (
|
||||
<Box className="flex h-full flex-col overflow-hidden px-4 pt-4 pb-0">
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<VendorsListToolbar
|
||||
globalCount={activeCount + inactiveCount}
|
||||
activeCount={activeCount}
|
||||
|
|
@ -154,15 +74,18 @@ export default function VendorsListPage() {
|
|||
onAddVendor={() => setCreateOpen(true)}
|
||||
/>
|
||||
|
||||
<VendorStatusTabs
|
||||
isActive={filters.isActive}
|
||||
activeCount={activeCount}
|
||||
inactiveCount={inactiveCount}
|
||||
onChange={filters.setIsActiveWithReset}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<VendorAppliedFiltersBar
|
||||
resultCount={totalCount}
|
||||
chips={appliedChips.map((chip) => ({
|
||||
key: `${chip.category}-${chip.value}`,
|
||||
label: chip.label,
|
||||
|
|
@ -179,18 +102,10 @@ export default function VendorsListPage() {
|
|||
|
||||
<VendorsTable
|
||||
rows={rows}
|
||||
isActive={filters.isActive}
|
||||
isFiltered={appliedChips.length > 0 || Boolean(filters.debouncedSearch)}
|
||||
isLoading={isLoading}
|
||||
showError={Boolean(error)}
|
||||
onOpenDetail={(row) => {
|
||||
setDetailStartsInEdit(false);
|
||||
setDetailTarget(row);
|
||||
}}
|
||||
onOpenEdit={(row) => {
|
||||
setDetailStartsInEdit(true);
|
||||
setDetailTarget(row);
|
||||
}}
|
||||
onOpenDetail={setDetailTarget}
|
||||
onOpenDeactivation={deactivation.open}
|
||||
/>
|
||||
|
||||
<VendorsListFooter
|
||||
|
|
@ -218,9 +133,7 @@ export default function VendorsListPage() {
|
|||
<VendorDetailDrawer
|
||||
open={Boolean(detailTarget)}
|
||||
vendor={detailTarget}
|
||||
startInEdit={detailStartsInEdit}
|
||||
onRequestDeactivation={deactivation.open}
|
||||
onClose={closeDetail}
|
||||
onClose={() => setDetailTarget(null)}
|
||||
/>
|
||||
|
||||
<VendorDeactivationDialog
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
import type { UpliftSummary } from "@/domain/work-orders/types/work-order-uplift";
|
||||
import {
|
||||
getUpliftCellLabel,
|
||||
getUpliftStatusPillStyle,
|
||||
} from "@/domain/work-orders/utils/uplift-display-utils";
|
||||
|
||||
type UpliftCellProps = {
|
||||
summary: UpliftSummary;
|
||||
onOpen?: () => void;
|
||||
};
|
||||
|
||||
export function UpliftCell({ summary, onOpen }: UpliftCellProps) {
|
||||
const label = getUpliftCellLabel(summary);
|
||||
|
||||
if (!onOpen) {
|
||||
return label ? (
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">{label}</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
|
||||
);
|
||||
}
|
||||
|
||||
const pillStatus = summary.pendingCount > 0 ? "pending" : summary.primaryStatus;
|
||||
const pillStyle = pillStatus ? getUpliftStatusPillStyle(pillStatus) : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
title={label ? "View uplifts" : "Manage uplifts"}
|
||||
className="flex w-full items-center justify-start transition-opacity hover:opacity-80"
|
||||
>
|
||||
{label && pillStyle ? (
|
||||
<span
|
||||
className="inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[11px] font-semibold whitespace-nowrap"
|
||||
style={pillStyle}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] font-semibold text-[var(--color-primary)]">Manage</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ type UseWoTableRowHandlersOptions = {
|
|||
onWeekChange: (mondayIso: string) => void;
|
||||
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
||||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
||||
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
|
||||
onScheduleHighlight: (id: string | number | null) => void;
|
||||
|
|
@ -27,6 +28,7 @@ export function useWoTableRowHandlers({
|
|||
onWeekChange,
|
||||
onOpenSlideOver,
|
||||
onOpenCompDoc,
|
||||
onOpenUplifts,
|
||||
onOpenMedia,
|
||||
onPatch,
|
||||
onScheduleHighlight,
|
||||
|
|
@ -60,6 +62,12 @@ export function useWoTableRowHandlers({
|
|||
onOpenCompDoc(row);
|
||||
}
|
||||
: undefined,
|
||||
onOpenUplifts: onOpenUplifts
|
||||
? (row: WorkOrderTableRow) => {
|
||||
tableData.setSelectedId(row.id);
|
||||
onOpenUplifts(row);
|
||||
}
|
||||
: undefined,
|
||||
onOpenMedia: (row: WorkOrderTableRow) => {
|
||||
tableData.setSelectedId(row.id);
|
||||
onOpenMedia(row);
|
||||
|
|
@ -135,6 +143,7 @@ export function useWoTableRowHandlers({
|
|||
onWeekChange,
|
||||
onOpenSlideOver,
|
||||
onOpenCompDoc,
|
||||
onOpenUplifts,
|
||||
onOpenMedia,
|
||||
onPatch,
|
||||
onScheduleHighlight,
|
||||
|
|
|
|||
|
|
@ -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: "uplift", label: "UPLIFT", w: 108 },
|
||||
{ key: "actions", label: "", w: 100 },
|
||||
] as const;
|
||||
|
||||
export const WO_TABLE_MIN_WIDTH = 1504;
|
||||
export const WO_TABLE_MIN_WIDTH = 1612;
|
||||
export const WO_TABLE_ROW_HEIGHT = 52;
|
||||
|
|
|
|||
|
|
@ -167,6 +167,10 @@ export function WoTableInlineRowCells({
|
|||
<td style={{ padding: "0 14px" }}>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
|
||||
</td>
|
||||
|
||||
<td style={{ padding: "0 14px" }}>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import { ApptTimeCell } from "./cells/appt-time-cell";
|
|||
import { CompDocCell } from "./cells/comp-doc-cell";
|
||||
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||
import { StatusCell } from "./cells/status-cell";
|
||||
import { UpliftCell } from "./cells/uplift-cell";
|
||||
import { VendorCell } from "./cells/vendor-cell";
|
||||
import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils";
|
||||
import type { WoTableRowHandlers } from "./wo-table-row";
|
||||
|
||||
type WoTableRowServiceCellsProps = {
|
||||
|
|
@ -99,6 +101,16 @@ export function WoTableRowServiceCells({
|
|||
onOpenMedia={() => handlers.onOpenMedia(row)}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
|
||||
<UpliftCell
|
||||
summary={row.upliftSummary}
|
||||
onOpen={
|
||||
canOpenUpliftsDialog(row.upliftSummary, row.status) && handlers.onOpenUplifts
|
||||
? () => handlers.onOpenUplifts!(row)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export type WoTableRowHandlers = {
|
|||
onView: (row: WorkOrderTableRow) => void;
|
||||
onEdit: (row: WorkOrderTableRow) => void;
|
||||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
||||
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => void;
|
||||
onScheduleDate: (id: string | number, date: string) => void;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ type WoTableProps = {
|
|||
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
||||
/** Omit until completion-doc slice mounts CompDocDialog. */
|
||||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
||||
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||
onClearSearch?: () => void;
|
||||
onOpenAdvancedFilters?: () => void;
|
||||
|
|
@ -49,6 +50,7 @@ export function WoTable({
|
|||
onWeekChange,
|
||||
onOpenSlideOver,
|
||||
onOpenCompDoc,
|
||||
onOpenUplifts,
|
||||
onOpenMedia,
|
||||
onClearSearch,
|
||||
onOpenAdvancedFilters,
|
||||
|
|
@ -67,6 +69,7 @@ export function WoTable({
|
|||
onWeekChange,
|
||||
onOpenSlideOver,
|
||||
onOpenCompDoc,
|
||||
onOpenUplifts,
|
||||
onOpenMedia,
|
||||
onPatch,
|
||||
onScheduleHighlight: setScheduleHighlightId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||
import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog";
|
||||
import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
type WorkOrdersListOverlaysProps = {
|
||||
activeUpliftsRow: WorkOrderTableRow | null;
|
||||
onCloseUplifts: () => void;
|
||||
activeMediaRow: WorkOrderTableRow | null;
|
||||
onCloseMedia: () => void;
|
||||
mediaFiles: Record<string, MediaFile[]>;
|
||||
onMediaChange: (rowId: string | number, files: MediaFile[]) => void;
|
||||
};
|
||||
|
||||
export function WorkOrdersListOverlays({
|
||||
activeUpliftsRow,
|
||||
onCloseUplifts,
|
||||
activeMediaRow,
|
||||
onCloseMedia,
|
||||
mediaFiles,
|
||||
onMediaChange,
|
||||
}: WorkOrdersListOverlaysProps) {
|
||||
return (
|
||||
<>
|
||||
{activeUpliftsRow != null && (
|
||||
<WorkOrderUpliftsDialog
|
||||
row={activeUpliftsRow}
|
||||
open={Boolean(activeUpliftsRow)}
|
||||
onClose={onCloseUplifts}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeMediaRow != null && (
|
||||
<MediaUploadModal
|
||||
row={activeMediaRow}
|
||||
open={Boolean(activeMediaRow)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onCloseMedia();
|
||||
}}
|
||||
uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA}
|
||||
onChange={(files) => onMediaChange(activeMediaRow.id, files)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
type RevokeUpliftDialogProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (reason: string) => void;
|
||||
pending?: boolean;
|
||||
requireReason?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export function RevokeUpliftDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
pending = false,
|
||||
requireReason = false,
|
||||
title = "Revoke auto-approved uplift?",
|
||||
description = "This will mark the uplift as revoked. Optional reason is stored with the decision.",
|
||||
}: RevokeUpliftDialogProps) {
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
// Clear only after the dialog closes (parent closes on mutation success).
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setReason("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(reason.trim());
|
||||
};
|
||||
|
||||
const reasonMissing = requireReason && reason.trim() === "";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Text as="p" variant="description" className="mb-3">
|
||||
{description}
|
||||
</Text>
|
||||
<TextField
|
||||
label={requireReason ? "Reason (required)" : "Reason (optional)"}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
required={requireReason}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={pending || reasonMissing}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { useState } from "react";
|
||||
import { Button, TextField } from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
type WorkOrderUpliftCreateFormProps = {
|
||||
pending?: boolean;
|
||||
onSubmit: (input: { amount: number; notes: string }) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function WorkOrderUpliftCreateForm({
|
||||
pending = false,
|
||||
onSubmit,
|
||||
}: WorkOrderUpliftCreateFormProps) {
|
||||
const [amount, setAmount] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const parsedAmount = Number(amount);
|
||||
const canSubmit = Number.isFinite(parsedAmount) && parsedAmount > 0 && notes.trim().length > 0;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await onSubmit({ amount: parsedAmount, notes: notes.trim() });
|
||||
setAmount("");
|
||||
setNotes("");
|
||||
} catch {
|
||||
// Mutation onError already toasts; keep form values after failure.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--color-border)] p-3">
|
||||
<Text as="p" variant="label" className="mb-2">
|
||||
Request uplift
|
||||
</Text>
|
||||
<div className="grid gap-2">
|
||||
<TextField
|
||||
label="Amount"
|
||||
type="number"
|
||||
size="small"
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
slotProps={{ htmlInput: { min: 0, step: "0.01" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Notes"
|
||||
size="small"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Attachments"
|
||||
size="small"
|
||||
disabled
|
||||
helperText="Attachment upload will be wired when BE accepts files."
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!canSubmit || pending}
|
||||
onClick={() => {
|
||||
void handleSubmit();
|
||||
}}
|
||||
>
|
||||
Create uplift
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import { Button } from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift";
|
||||
import {
|
||||
canRevokeWorkOrderUplift,
|
||||
formatUpliftMoney,
|
||||
formatUpliftStatusLabel,
|
||||
getUpliftStatusPillStyle,
|
||||
} from "@/domain/work-orders/utils/uplift-display-utils";
|
||||
|
||||
type WorkOrderUpliftListItemProps = {
|
||||
uplift: WorkOrderUplift;
|
||||
readOnly: boolean;
|
||||
isAdmin: boolean;
|
||||
currentUserId: string | number | null | undefined;
|
||||
pendingAction?: boolean;
|
||||
onCancel?: () => void;
|
||||
onRevoke?: () => void;
|
||||
};
|
||||
|
||||
export function WorkOrderUpliftListItem({
|
||||
uplift,
|
||||
readOnly,
|
||||
isAdmin,
|
||||
currentUserId,
|
||||
pendingAction = false,
|
||||
onCancel,
|
||||
onRevoke,
|
||||
}: WorkOrderUpliftListItemProps) {
|
||||
const pillStyle = getUpliftStatusPillStyle(uplift.status);
|
||||
const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel);
|
||||
const showRevoke =
|
||||
!readOnly && canRevokeWorkOrderUplift(uplift, currentUserId, isAdmin) && Boolean(onRevoke);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--color-border)] p-3">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className="inline-flex rounded-full px-2 py-0.5 text-[11px] font-semibold"
|
||||
style={pillStyle}
|
||||
>
|
||||
{formatUpliftStatusLabel(uplift.status)}
|
||||
</span>
|
||||
<Text as="span" variant="label">
|
||||
{formatUpliftMoney(uplift.amount)}
|
||||
</Text>
|
||||
</div>
|
||||
{Boolean(uplift.notes) && (
|
||||
<Text as="p" variant="description" className="mb-2">
|
||||
{uplift.notes}
|
||||
</Text>
|
||||
)}
|
||||
{Boolean(uplift.requestedByName) && (
|
||||
<Text as="p" variant="caption" tone="muted">
|
||||
Requested by {uplift.requestedByName}
|
||||
</Text>
|
||||
)}
|
||||
{showCancel && (
|
||||
<Button
|
||||
size="small"
|
||||
color="inherit"
|
||||
disabled={pendingAction}
|
||||
onClick={onCancel}
|
||||
className="mt-2"
|
||||
>
|
||||
Cancel pending
|
||||
</Button>
|
||||
)}
|
||||
{showRevoke && (
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
disabled={pendingAction}
|
||||
onClick={onRevoke}
|
||||
className="mt-2"
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { CircularProgress } from "@mui/material";
|
||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { WorkOrderUpliftCreateForm } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form";
|
||||
import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item";
|
||||
import type {
|
||||
CreateWorkOrderUpliftInput,
|
||||
WorkOrderUplift,
|
||||
} from "@/domain/work-orders/types/work-order-uplift";
|
||||
|
||||
type WorkOrderUpliftsDialogContentProps = {
|
||||
readOnly: boolean;
|
||||
isAdmin: boolean;
|
||||
currentUserId: string | number | null | undefined;
|
||||
readOnlyStatusLabel?: string;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
uplifts: WorkOrderUplift[];
|
||||
actionPending: boolean;
|
||||
createPending: boolean;
|
||||
onRetry: () => void;
|
||||
onCreate: (input: CreateWorkOrderUpliftInput) => void | Promise<void>;
|
||||
onCancelUplift: (upliftId: string | number) => void;
|
||||
onRevokeUplift: (upliftId: string | number) => void;
|
||||
};
|
||||
|
||||
export function WorkOrderUpliftsDialogContent({
|
||||
readOnly,
|
||||
isAdmin,
|
||||
currentUserId,
|
||||
readOnlyStatusLabel,
|
||||
isLoading,
|
||||
error,
|
||||
uplifts,
|
||||
actionPending,
|
||||
createPending,
|
||||
onRetry,
|
||||
onCreate,
|
||||
onCancelUplift,
|
||||
onRevokeUplift,
|
||||
}: WorkOrderUpliftsDialogContentProps) {
|
||||
return (
|
||||
<>
|
||||
{readOnly && (
|
||||
<Text as="p" variant="description" tone="muted">
|
||||
This work order is {readOnlyStatusLabel?.toLowerCase()}. Uplifts are read-only.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{Boolean(error) && (
|
||||
<QueryErrorPanel
|
||||
compact
|
||||
message={error instanceof Error ? error.message : "Failed to load uplifts."}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-6">
|
||||
<CircularProgress size={24} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && uplifts.length === 0 && (
|
||||
<Text as="p" variant="description" tone="muted">
|
||||
No uplift requests yet.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
uplifts.map((uplift) => (
|
||||
<WorkOrderUpliftListItem
|
||||
key={String(uplift.id)}
|
||||
uplift={uplift}
|
||||
readOnly={readOnly}
|
||||
isAdmin={isAdmin}
|
||||
currentUserId={currentUserId}
|
||||
pendingAction={actionPending}
|
||||
onCancel={readOnly ? undefined : () => onCancelUplift(uplift.id)}
|
||||
onRevoke={readOnly ? undefined : () => onRevokeUplift(uplift.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!readOnly && !isLoading && (
|
||||
<WorkOrderUpliftCreateForm pending={createPending} onSubmit={onCreate} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { RevokeUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog";
|
||||
import { WorkOrderUpliftsDialogContent } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WorkOrderUpliftStatus } from "@/domain/work-orders/types/work-order-uplift";
|
||||
import {
|
||||
useCancelWorkOrderUplift,
|
||||
useCreateWorkOrderUplift,
|
||||
useRevokeWorkOrderUplift,
|
||||
useWorkOrderUplifts,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-uplifts";
|
||||
import {
|
||||
isWorkOrderUpliftsReadOnly,
|
||||
upliftRevokeRequiresReason,
|
||||
} from "@/domain/work-orders/utils/uplift-display-utils";
|
||||
import { isAdminUser } from "@/lib/auth/user-utils";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
||||
type RevokeTarget = {
|
||||
id: string | number;
|
||||
status: WorkOrderUpliftStatus;
|
||||
};
|
||||
|
||||
type WorkOrderUpliftsDialogProps = {
|
||||
row: WorkOrderTableRow | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function WorkOrderUpliftsDialog({ row, open, onClose }: WorkOrderUpliftsDialogProps) {
|
||||
const { user } = useAuthContext();
|
||||
const isAdmin = isAdminUser(user?.userRoles);
|
||||
const workOrderId = row?.id ?? null;
|
||||
const readOnly = row ? isWorkOrderUpliftsReadOnly(row.status) : true;
|
||||
const {
|
||||
data: uplifts = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useWorkOrderUplifts(open ? workOrderId : null);
|
||||
const createUplift = useCreateWorkOrderUplift(workOrderId ?? "");
|
||||
const cancelUplift = useCancelWorkOrderUplift(workOrderId ?? "");
|
||||
const revokeUplift = useRevokeWorkOrderUplift(workOrderId ?? "");
|
||||
const [revokeTarget, setRevokeTarget] = useState<RevokeTarget | null>(null);
|
||||
|
||||
const actionPending = createUplift.isPending || cancelUplift.isPending || revokeUplift.isPending;
|
||||
|
||||
const revokeDialogCopy = useMemo(() => {
|
||||
if (revokeTarget?.status === "approved") {
|
||||
return {
|
||||
title: "Revoke approved uplift?",
|
||||
description:
|
||||
"Admin revoke requires a reason. This will mark the uplift as revoked and store your note with the decision.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "Revoke auto-approved uplift?",
|
||||
description:
|
||||
"This will mark the uplift as revoked. Optional reason is stored with the decision.",
|
||||
};
|
||||
}, [revokeTarget?.status]);
|
||||
|
||||
const handleRevokeUplift = (upliftId: string | number) => {
|
||||
const uplift = uplifts.find((item) => String(item.id) === String(upliftId));
|
||||
if (!uplift) {
|
||||
return;
|
||||
}
|
||||
setRevokeTarget({ id: upliftId, status: uplift.status });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Uplifts — WO {row?.woNumber ?? ""}</DialogTitle>
|
||||
<DialogContent className="flex flex-col gap-3">
|
||||
<WorkOrderUpliftsDialogContent
|
||||
readOnly={readOnly}
|
||||
isAdmin={isAdmin}
|
||||
currentUserId={user?.id}
|
||||
readOnlyStatusLabel={row?.status}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
uplifts={uplifts}
|
||||
actionPending={actionPending}
|
||||
createPending={createUplift.isPending}
|
||||
onRetry={() => void refetch()}
|
||||
onCreate={async (input) => {
|
||||
await createUplift.mutateAsync(input);
|
||||
}}
|
||||
onCancelUplift={(upliftId) => cancelUplift.mutate(upliftId)}
|
||||
onRevokeUplift={handleRevokeUplift}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<RevokeUpliftDialog
|
||||
open={revokeTarget != null}
|
||||
pending={revokeUplift.isPending}
|
||||
requireReason={
|
||||
revokeTarget != null ? upliftRevokeRequiresReason(revokeTarget.status) : false
|
||||
}
|
||||
title={revokeDialogCopy.title}
|
||||
description={revokeDialogCopy.description}
|
||||
onClose={() => setRevokeTarget(null)}
|
||||
onConfirm={(reason) => {
|
||||
if (revokeTarget == null) {
|
||||
return;
|
||||
}
|
||||
revokeUplift.mutate(
|
||||
{ upliftId: revokeTarget.id, reason },
|
||||
{ onSuccess: () => setRevokeTarget(null) },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { Box } from "@mui/material";
|
|||
import { useNavigate } from "react-router";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||
import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays";
|
||||
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";
|
||||
|
|
@ -18,7 +18,7 @@ import {
|
|||
useWorkOrderVendors,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
|
@ -28,6 +28,7 @@ export default function WorkOrdersListPage() {
|
|||
const { user } = useAuthContext();
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
|
||||
const [upliftsRow, setUpliftsRow] = useState<WorkOrderTableRow | null>(null);
|
||||
const [mediaFiles, setMediaFiles] = useState<Record<string, MediaFile[]>>({});
|
||||
const filters = useWorkOrdersListFilters();
|
||||
const tableData = useWorkOrdersTableData(filters);
|
||||
|
|
@ -55,6 +56,11 @@ export default function WorkOrdersListPage() {
|
|||
const totalCount = tableData.totalCount;
|
||||
const scopeTotalCount = tableData.scopeTotalCount;
|
||||
|
||||
const activeUpliftsRow = useMemo(() => {
|
||||
if (!upliftsRow) return null;
|
||||
return tableData.rows.find((row) => row.id === upliftsRow.id) ?? upliftsRow;
|
||||
}, [upliftsRow, tableData.rows]);
|
||||
|
||||
const activeMediaRow = useMemo(() => {
|
||||
if (!mediaRow) return null;
|
||||
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
|
||||
|
|
@ -129,6 +135,7 @@ export default function WorkOrdersListPage() {
|
|||
navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`);
|
||||
}}
|
||||
onOpenMedia={setMediaRow}
|
||||
onOpenUplifts={setUpliftsRow}
|
||||
onClearSearch={() => filters.handleSearchChange("")}
|
||||
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||
onPatch={patchField}
|
||||
|
|
@ -148,20 +155,17 @@ export default function WorkOrdersListPage() {
|
|||
|
||||
<NewWoWizard open={wizardOpen} onOpenChange={setWizardOpen} users={users} />
|
||||
|
||||
{activeMediaRow != null && (
|
||||
<MediaUploadModal
|
||||
row={activeMediaRow}
|
||||
open={!!activeMediaRow}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setMediaRow(null);
|
||||
}}
|
||||
uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA}
|
||||
onChange={(files) => {
|
||||
setMediaFiles((prev) => ({ ...prev, [String(activeMediaRow.id)]: files }));
|
||||
patchField(activeMediaRow.id, { mediaCount: files.length });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<WorkOrdersListOverlays
|
||||
activeUpliftsRow={activeUpliftsRow}
|
||||
onCloseUplifts={() => setUpliftsRow(null)}
|
||||
activeMediaRow={activeMediaRow}
|
||||
onCloseMedia={() => setMediaRow(null)}
|
||||
mediaFiles={mediaFiles}
|
||||
onMediaChange={(rowId, files) => {
|
||||
setMediaFiles((prev) => ({ ...prev, [String(rowId)]: files }));
|
||||
patchField(rowId, { mediaCount: files.length });
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,17 +49,10 @@ 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"
|
||||
as="span"
|
||||
tone="muted"
|
||||
when={Boolean(subtitle)}
|
||||
sx={{ mt: 0.75, fontWeight: 500 }}
|
||||
>
|
||||
<Text variant="description" tone="muted" when={Boolean(subtitle)}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
|
|
|||
61
src/domain/work-orders/api/work-order-uplifts-api.ts
Normal file
61
src/domain/work-orders/api/work-order-uplifts-api.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { apiGet, apiPost } from "@/api/api";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import {
|
||||
mapWorkOrderUplift,
|
||||
mapWorkOrderUplifts,
|
||||
} from "@/domain/work-orders/mappers/work-order-uplift-mapper";
|
||||
import type {
|
||||
CreateWorkOrderUpliftInput,
|
||||
WorkOrderUplift,
|
||||
} from "@/domain/work-orders/types/work-order-uplift";
|
||||
|
||||
function extractUpliftItems(data: unknown): unknown[] {
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
const record = typeof data === "object" && data != null ? (data as Record<string, unknown>) : {};
|
||||
const items = record.items ?? record.Items ?? record.uplifts ?? record.Uplifts;
|
||||
return Array.isArray(items) ? items : [];
|
||||
}
|
||||
|
||||
export const workOrderUpliftsApi = {
|
||||
list: async (workOrderId: string | number): Promise<WorkOrderUplift[]> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.workOrder.uplifts(workOrderId));
|
||||
const unwrapped = handleApiResponse<unknown>(data);
|
||||
return mapWorkOrderUplifts(extractUpliftItems(unwrapped));
|
||||
},
|
||||
|
||||
create: async (
|
||||
workOrderId: string | number,
|
||||
input: CreateWorkOrderUpliftInput,
|
||||
): Promise<WorkOrderUplift> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.workOrder.uplifts(workOrderId), {
|
||||
amount: input.amount,
|
||||
notes: input.notes,
|
||||
});
|
||||
return mapWorkOrderUplift(handleApiResponse(data));
|
||||
},
|
||||
|
||||
cancel: async (
|
||||
workOrderId: string | number,
|
||||
upliftId: string | number,
|
||||
): Promise<WorkOrderUplift> => {
|
||||
const data = await apiPost<unknown>(
|
||||
API_PATHS.workOrder.upliftCancel(workOrderId, upliftId),
|
||||
{},
|
||||
);
|
||||
return mapWorkOrderUplift(handleApiResponse(data));
|
||||
},
|
||||
|
||||
revoke: async (
|
||||
workOrderId: string | number,
|
||||
upliftId: string | number,
|
||||
reason?: string,
|
||||
): Promise<WorkOrderUplift> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.workOrder.upliftRevoke(workOrderId, upliftId), {
|
||||
reason,
|
||||
});
|
||||
return mapWorkOrderUplift(handleApiResponse(data));
|
||||
},
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
||||
import { mapUpliftSummary } from "@/domain/work-orders/mappers/work-order-uplift-mapper";
|
||||
import {
|
||||
DOC_STATUS_API,
|
||||
LIFECYCLE_STATUS_API_TO_FE,
|
||||
|
|
@ -272,6 +273,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
|||
rowVersion: readRowVersion(item, "rowVersion", "RowVersion"),
|
||||
dispatchRowVersion: readRowVersion(item, "dispatchRowVersion", "DispatchRowVersion"),
|
||||
flagColor: readFlagColor(item),
|
||||
upliftSummary: mapUpliftSummary(item),
|
||||
};
|
||||
|
||||
return row;
|
||||
|
|
|
|||
152
src/domain/work-orders/mappers/work-order-uplift-mapper.ts
Normal file
152
src/domain/work-orders/mappers/work-order-uplift-mapper.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import type {
|
||||
UpliftSummary,
|
||||
WorkOrderUplift,
|
||||
WorkOrderUpliftStatus,
|
||||
} from "@/domain/work-orders/types/work-order-uplift";
|
||||
import { WORK_ORDER_UPLIFT_STATUSES } from "@/domain/work-orders/types/work-order-uplift";
|
||||
|
||||
function asRecord(raw: unknown): Record<string, unknown> {
|
||||
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function readNumber(record: Record<string, unknown>, ...keys: string[]): number | null {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value !== "") {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readId(record: Record<string, unknown>): string | number {
|
||||
const value = record.id ?? record.Id;
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function readOptionalId(
|
||||
record: Record<string, unknown>,
|
||||
...keys: string[]
|
||||
): string | number | null {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const STATUS_SET = new Set<string>(WORK_ORDER_UPLIFT_STATUSES);
|
||||
|
||||
function normalizeUpliftStatus(raw: string): WorkOrderUpliftStatus {
|
||||
const normalized = raw.trim().toLowerCase().replace(/\s+/g, "_");
|
||||
if (STATUS_SET.has(normalized)) {
|
||||
return normalized as WorkOrderUpliftStatus;
|
||||
}
|
||||
if (normalized === "canceled") {
|
||||
return "cancelled";
|
||||
}
|
||||
if (normalized === "autoapproved") {
|
||||
return "auto_approved";
|
||||
}
|
||||
return "pending";
|
||||
}
|
||||
|
||||
export function mapUpliftSummary(raw: unknown): UpliftSummary {
|
||||
const item = asRecord(raw);
|
||||
const nested = asRecord(item.upliftSummary ?? item.UpliftSummary);
|
||||
const source = Object.keys(nested).length > 0 ? nested : item;
|
||||
|
||||
const pendingCount =
|
||||
readNumber(source, "pendingCount", "PendingCount") ??
|
||||
readNumber(item, "pendingUpliftCount", "PendingUpliftCount") ??
|
||||
0;
|
||||
const hasUplift =
|
||||
readBool(source, "hasUplift", "HasUplift") ||
|
||||
readBool(item, "hasUplift", "HasUplift") ||
|
||||
pendingCount > 0;
|
||||
|
||||
const primaryStatusRaw = readString(source, "primaryStatus", "PrimaryStatus", "status", "Status");
|
||||
const amount = readNumber(source, "amount", "Amount");
|
||||
|
||||
const summary: UpliftSummary = {
|
||||
hasUplift,
|
||||
pendingCount,
|
||||
};
|
||||
|
||||
if (primaryStatusRaw) {
|
||||
summary.primaryStatus = normalizeUpliftStatus(primaryStatusRaw);
|
||||
}
|
||||
if (amount != null) {
|
||||
summary.amount = amount;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function mapWorkOrderUplift(raw: unknown): WorkOrderUplift {
|
||||
const item = asRecord(raw);
|
||||
const statusRaw = readString(item, "status", "Status");
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
status: statusRaw ? normalizeUpliftStatus(statusRaw) : "pending",
|
||||
amount: readNumber(item, "amount", "Amount", "requestedNTE", "RequestedNTE") ?? 0,
|
||||
notes: readString(item, "notes", "Notes", "vendorReason", "VendorReason"),
|
||||
requestedAt: readString(item, "requestedAt", "RequestedAt"),
|
||||
requestedById: readOptionalId(
|
||||
item,
|
||||
"requestedById",
|
||||
"RequestedById",
|
||||
"requesterId",
|
||||
"RequesterId",
|
||||
),
|
||||
requestedByName: readString(
|
||||
item,
|
||||
"requestedByName",
|
||||
"RequestedByName",
|
||||
"requestedByVendorName",
|
||||
"RequestedByVendorName",
|
||||
),
|
||||
decidedAt: readString(item, "decidedAt", "DecidedAt"),
|
||||
decidedByName: readString(item, "decidedByName", "DecidedByName"),
|
||||
decisionNote: readString(item, "decisionNote", "DecisionNote"),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapWorkOrderUplifts(rawItems: unknown[]): WorkOrderUplift[] {
|
||||
return rawItems.map(mapWorkOrderUplift);
|
||||
}
|
||||
123
src/domain/work-orders/mocks/work-order-uplifts-mock-store.ts
Normal file
123
src/domain/work-orders/mocks/work-order-uplifts-mock-store.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import type {
|
||||
CreateWorkOrderUpliftInput,
|
||||
UpliftSummary,
|
||||
WorkOrderUplift,
|
||||
} from "@/domain/work-orders/types/work-order-uplift";
|
||||
|
||||
const store = new Map<string, WorkOrderUplift[]>();
|
||||
let nextId = 9000;
|
||||
|
||||
function keyFor(workOrderId: string | number): string {
|
||||
return String(workOrderId);
|
||||
}
|
||||
|
||||
function computeSummary(items: WorkOrderUplift[]): UpliftSummary {
|
||||
if (items.length === 0) {
|
||||
return { hasUplift: false, pendingCount: 0 };
|
||||
}
|
||||
|
||||
const pendingCount = items.filter((item) => item.status === "pending").length;
|
||||
const primary = items[0];
|
||||
|
||||
return {
|
||||
hasUplift: true,
|
||||
pendingCount,
|
||||
primaryStatus: primary.status,
|
||||
amount: primary.amount,
|
||||
};
|
||||
}
|
||||
|
||||
function seedIfEmpty(workOrderId: string | number): WorkOrderUplift[] {
|
||||
const key = keyFor(workOrderId);
|
||||
const existing = store.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const numericId = Number(workOrderId);
|
||||
if (!Number.isNaN(numericId) && numericId % 3 === 0) {
|
||||
const seeded: WorkOrderUplift[] = [
|
||||
{
|
||||
id: nextId++,
|
||||
status: "pending",
|
||||
amount: 450,
|
||||
notes: "Additional coil cleaning required",
|
||||
requestedAt: new Date().toISOString(),
|
||||
requestedById: "mock-vendor",
|
||||
requestedByName: "Mock Vendor",
|
||||
decidedAt: "",
|
||||
decidedByName: "",
|
||||
decisionNote: "",
|
||||
},
|
||||
];
|
||||
store.set(key, seeded);
|
||||
return seeded;
|
||||
}
|
||||
|
||||
store.set(key, []);
|
||||
return [];
|
||||
}
|
||||
|
||||
export const workOrderUpliftsMockStore = {
|
||||
list(workOrderId: string | number): WorkOrderUplift[] {
|
||||
return [...seedIfEmpty(workOrderId)];
|
||||
},
|
||||
|
||||
create(workOrderId: string | number, input: CreateWorkOrderUpliftInput): WorkOrderUplift {
|
||||
const items = seedIfEmpty(workOrderId);
|
||||
const created: WorkOrderUplift = {
|
||||
id: nextId++,
|
||||
status: "pending",
|
||||
amount: input.amount,
|
||||
notes: input.notes,
|
||||
requestedAt: new Date().toISOString(),
|
||||
requestedById: "internal-user",
|
||||
requestedByName: "Internal User",
|
||||
decidedAt: "",
|
||||
decidedByName: "",
|
||||
decisionNote: "",
|
||||
};
|
||||
items.unshift(created);
|
||||
store.set(keyFor(workOrderId), items);
|
||||
return created;
|
||||
},
|
||||
|
||||
cancel(workOrderId: string | number, upliftId: string | number): WorkOrderUplift {
|
||||
const items = seedIfEmpty(workOrderId);
|
||||
const target = items.find((item) => String(item.id) === String(upliftId));
|
||||
if (!target) {
|
||||
throw new Error("Uplift not found");
|
||||
}
|
||||
if (target.status !== "pending") {
|
||||
throw new Error("Only pending uplifts can be cancelled");
|
||||
}
|
||||
target.status = "cancelled";
|
||||
return target;
|
||||
},
|
||||
|
||||
revoke(
|
||||
workOrderId: string | number,
|
||||
upliftId: string | number,
|
||||
reason?: string,
|
||||
): WorkOrderUplift {
|
||||
const items = seedIfEmpty(workOrderId);
|
||||
const target = items.find((item) => String(item.id) === String(upliftId));
|
||||
if (!target) {
|
||||
throw new Error("Uplift not found");
|
||||
}
|
||||
if (target.status !== "auto_approved" && target.status !== "approved") {
|
||||
throw new Error("Only approved or auto-approved uplifts can be revoked");
|
||||
}
|
||||
if (target.status === "approved" && !reason?.trim()) {
|
||||
throw new Error("Revoke reason is required for approved uplifts");
|
||||
}
|
||||
target.status = "revoked";
|
||||
target.decisionNote = reason?.trim() ?? "";
|
||||
target.decidedAt = new Date().toISOString();
|
||||
return target;
|
||||
},
|
||||
|
||||
summary(workOrderId: string | number): UpliftSummary {
|
||||
return computeSummary(seedIfEmpty(workOrderId));
|
||||
},
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { WizardWOStatus, WOType } from "./work-order-wizard";
|
||||
import type { UpliftSummary } from "./work-order-uplift";
|
||||
|
||||
export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
|
||||
|
||||
|
|
@ -49,6 +50,7 @@ export interface WorkOrderTableRow {
|
|||
dispatchRowVersion: string;
|
||||
/** Board flag color (#RRGGBB). Null/empty = no flag. Distinct from dispatcher avatar `color`. */
|
||||
flagColor: string | null;
|
||||
upliftSummary: UpliftSummary;
|
||||
}
|
||||
|
||||
export interface WorkOrderTableDraft {
|
||||
|
|
|
|||
41
src/domain/work-orders/types/work-order-uplift.ts
Normal file
41
src/domain/work-orders/types/work-order-uplift.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
export const WORK_ORDER_UPLIFT_STATUSES = [
|
||||
"pending",
|
||||
"approved",
|
||||
"auto_approved",
|
||||
"rejected",
|
||||
"cancelled",
|
||||
"revoked",
|
||||
] as const;
|
||||
|
||||
export type WorkOrderUpliftStatus = (typeof WORK_ORDER_UPLIFT_STATUSES)[number];
|
||||
|
||||
export interface UpliftSummary {
|
||||
hasUplift: boolean;
|
||||
pendingCount: number;
|
||||
primaryStatus?: WorkOrderUpliftStatus;
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export interface WorkOrderUplift {
|
||||
id: string | number;
|
||||
status: WorkOrderUpliftStatus;
|
||||
amount: number;
|
||||
notes: string;
|
||||
requestedAt: string;
|
||||
requestedById: string | number | null;
|
||||
requestedByName: string;
|
||||
decidedAt: string;
|
||||
decidedByName: string;
|
||||
decisionNote: string;
|
||||
}
|
||||
|
||||
export interface CreateWorkOrderUpliftInput {
|
||||
amount: number;
|
||||
notes: string;
|
||||
attachments?: File[];
|
||||
}
|
||||
|
||||
export const EMPTY_UPLIFT_SUMMARY: UpliftSummary = {
|
||||
hasUplift: false,
|
||||
pendingCount: 0,
|
||||
};
|
||||
87
src/domain/work-orders/use-cases/use-work-order-uplifts.ts
Normal file
87
src/domain/work-orders/use-cases/use-work-order-uplifts.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseMutationResult,
|
||||
type UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api";
|
||||
import type {
|
||||
CreateWorkOrderUpliftInput,
|
||||
WorkOrderUplift,
|
||||
} from "@/domain/work-orders/types/work-order-uplift";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import { requireQueryParam } from "@/lib/query/require-query-param";
|
||||
|
||||
function invalidateUpliftQueries(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
workOrderId: string | number,
|
||||
): void {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.uplifts(workOrderId) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||
}
|
||||
|
||||
export function useWorkOrderUplifts(
|
||||
workOrderId: string | number | null | undefined,
|
||||
): UseQueryResult<WorkOrderUplift[], Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.workOrders.uplifts(workOrderId ?? ""),
|
||||
queryFn: () =>
|
||||
workOrderUpliftsApi.list(requireQueryParam(workOrderId ?? undefined, "Work order id")),
|
||||
enabled: workOrderId != null && workOrderId !== "",
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateWorkOrderUplift(
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<WorkOrderUplift, Error, CreateWorkOrderUpliftInput> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateWorkOrderUpliftInput) =>
|
||||
workOrderUpliftsApi.create(workOrderId, input),
|
||||
onSuccess: () => {
|
||||
invalidateUpliftQueries(queryClient, workOrderId);
|
||||
toast.success("Uplift request created");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to create uplift");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancelWorkOrderUplift(
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<WorkOrderUplift, Error, string | number> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (upliftId: string | number) => workOrderUpliftsApi.cancel(workOrderId, upliftId),
|
||||
onSuccess: () => {
|
||||
invalidateUpliftQueries(queryClient, workOrderId);
|
||||
toast.success("Uplift cancelled");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to cancel uplift");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeWorkOrderUplift(
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<WorkOrderUplift, Error, { upliftId: string | number; reason?: string }> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ upliftId, reason }: { upliftId: string | number; reason?: string }) =>
|
||||
workOrderUpliftsApi.revoke(workOrderId, upliftId, reason),
|
||||
onSuccess: () => {
|
||||
invalidateUpliftQueries(queryClient, workOrderId);
|
||||
toast.success("Uplift revoked");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to revoke uplift");
|
||||
},
|
||||
});
|
||||
}
|
||||
103
src/domain/work-orders/utils/uplift-display-utils.ts
Normal file
103
src/domain/work-orders/utils/uplift-display-utils.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import type {
|
||||
WorkOrderUplift,
|
||||
UpliftSummary,
|
||||
WorkOrderUpliftStatus,
|
||||
} from "@/domain/work-orders/types/work-order-uplift";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
export function formatUpliftMoney(value: number | null | undefined): string {
|
||||
if (value == null || Number.isNaN(value)) {
|
||||
return "—";
|
||||
}
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<WorkOrderUpliftStatus, string> = {
|
||||
pending: "Pending",
|
||||
approved: "Approved",
|
||||
auto_approved: "Auto",
|
||||
rejected: "Rejected",
|
||||
cancelled: "Cancelled",
|
||||
revoked: "Revoked",
|
||||
};
|
||||
|
||||
export function formatUpliftStatusLabel(status: WorkOrderUpliftStatus): string {
|
||||
return STATUS_LABELS[status];
|
||||
}
|
||||
|
||||
export function getUpliftCellLabel(summary: UpliftSummary): string | null {
|
||||
if (summary.pendingCount > 0) {
|
||||
return summary.pendingCount === 1 ? "1 Pending" : `${summary.pendingCount} Pending`;
|
||||
}
|
||||
if (summary.primaryStatus) {
|
||||
const label = formatUpliftStatusLabel(summary.primaryStatus);
|
||||
if (summary.amount != null) {
|
||||
return `${label} ${formatUpliftMoney(summary.amount)}`;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
if (summary.hasUplift) {
|
||||
return "Uplift";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getUpliftStatusPillStyle(status: WorkOrderUpliftStatus): {
|
||||
background: string;
|
||||
color: string;
|
||||
} {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return { background: "var(--status-pending-bg)", color: "var(--status-pending-text)" };
|
||||
case "approved":
|
||||
return { background: "var(--status-success-bg, #DCFCE7)", color: "var(--color-success)" };
|
||||
case "auto_approved":
|
||||
return { background: "var(--color-primary-tint)", color: "var(--color-primary)" };
|
||||
case "rejected":
|
||||
return { background: "#FEE2E2", color: "#B91C1C" };
|
||||
case "cancelled":
|
||||
case "revoked":
|
||||
return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" };
|
||||
default:
|
||||
return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" };
|
||||
}
|
||||
}
|
||||
|
||||
export function isWorkOrderUpliftsReadOnly(status: WorkOrderTableRow["status"]): boolean {
|
||||
return status === "Completed" || status === "Canceled";
|
||||
}
|
||||
|
||||
export function canOpenUpliftsDialog(
|
||||
summary: UpliftSummary,
|
||||
status: WorkOrderTableRow["status"],
|
||||
): boolean {
|
||||
if (summary.hasUplift || summary.pendingCount > 0) {
|
||||
return true;
|
||||
}
|
||||
return !isWorkOrderUpliftsReadOnly(status);
|
||||
}
|
||||
|
||||
export function canRevokeWorkOrderUplift(
|
||||
uplift: Pick<WorkOrderUplift, "status" | "requestedById">,
|
||||
currentUserId: string | number | null | undefined,
|
||||
isAdmin: boolean,
|
||||
): boolean {
|
||||
if (uplift.status === "auto_approved") {
|
||||
// SH-196: auto-approved revoke is dispatcher-own only (no admin bypass).
|
||||
return (
|
||||
currentUserId != null &&
|
||||
currentUserId !== "" &&
|
||||
uplift.requestedById != null &&
|
||||
String(uplift.requestedById) === String(currentUserId)
|
||||
);
|
||||
}
|
||||
return isAdmin && uplift.status === "approved";
|
||||
}
|
||||
|
||||
export function upliftRevokeRequiresReason(status: WorkOrderUpliftStatus): boolean {
|
||||
return status === "approved";
|
||||
}
|
||||
|
|
@ -25,6 +25,8 @@ export const queryKeys = {
|
|||
dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const,
|
||||
taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const,
|
||||
dispatchers: () => [...queryKeys.workOrders.all, "dispatchers"] as const,
|
||||
uplifts: (workOrderId: string | number) =>
|
||||
[...queryKeys.workOrders.all, "uplifts", workOrderId] as const,
|
||||
},
|
||||
pmSchedules: {
|
||||
all: ["pmSchedules"] as const,
|
||||
|
|
|
|||
|
|
@ -4,3 +4,13 @@ export function getPrimaryUserRole(
|
|||
): string {
|
||||
return userRoles?.split(",")[0]?.trim() || fallback;
|
||||
}
|
||||
|
||||
export function isAdminUser(userRoles: string | null | undefined): boolean {
|
||||
if (!userRoles) {
|
||||
return false;
|
||||
}
|
||||
return userRoles
|
||||
.split(",")
|
||||
.map((role) => role.trim().toLowerCase())
|
||||
.includes("admin");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ 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) },
|
||||
|
|
@ -29,10 +28,7 @@ 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")
|
||||
>("@/domain/vendors/use-cases/use-save-vendor-company-roster");
|
||||
return {
|
||||
...actual,
|
||||
useSaveVendorCompanyRoster: () => ({ mutate: saveMutate, isPending: false }),
|
||||
};
|
||||
return { ...actual, useSaveVendorCompanyRoster: () => ({ mutate: vi.fn(), isPending: false }) };
|
||||
});
|
||||
|
||||
import { useVendorRosterForm } from "@/app/(protected)/vendors/_components/use-vendor-roster-form";
|
||||
|
|
@ -105,7 +101,6 @@ function deferRoster(): {
|
|||
describe("useVendorRosterForm load-error handling", () => {
|
||||
beforeEach(() => {
|
||||
rosterGet.mockReset();
|
||||
saveMutate.mockReset();
|
||||
});
|
||||
|
||||
it("shows a safe retryable error when selecting an existing company fetch is rejected", async () => {
|
||||
|
|
@ -201,44 +196,6 @@ 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();
|
||||
|
|
|
|||
|
|
@ -49,11 +49,11 @@ function rosterWith(technicians: Array<Record<string, unknown>>) {
|
|||
};
|
||||
}
|
||||
|
||||
describe("VendorDetailDrawer selected-technician display", () => {
|
||||
describe("VendorDetailDrawer preferred-contact display", () => {
|
||||
it("renders no preference label when preferredContact is absent", () => {
|
||||
useVendorCompanyRoster.mockReturnValue(
|
||||
rosterWith([
|
||||
{ id: 1, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 },
|
||||
{ id: 7, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 },
|
||||
]),
|
||||
);
|
||||
|
||||
|
|
@ -62,15 +62,15 @@ describe("VendorDetailDrawer selected-technician display", () => {
|
|||
withAuth: false,
|
||||
});
|
||||
|
||||
expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Adam")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the retired preferred-contact value out of the detail view", () => {
|
||||
it("renders the preference label when an explicit preferredContact is present", () => {
|
||||
useVendorCompanyRoster.mockReturnValue(
|
||||
rosterWith([
|
||||
{
|
||||
id: 1,
|
||||
id: 7,
|
||||
contactName: "Adam",
|
||||
phone: "314-555-0198",
|
||||
preferredContact: "Email",
|
||||
|
|
@ -85,52 +85,6 @@ describe("VendorDetailDrawer selected-technician display", () => {
|
|||
withAuth: false,
|
||||
});
|
||||
|
||||
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();
|
||||
expect(screen.getByText("Prefers Email")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
import { screen, within } from "@testing-library/react";
|
||||
import { screen } 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 { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor";
|
||||
import type { 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),
|
||||
}));
|
||||
|
|
@ -54,32 +61,6 @@ 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 },
|
||||
|
|
@ -90,13 +71,7 @@ function result(items: VendorListItem[], totalCount: number) {
|
|||
}
|
||||
|
||||
function setupDefaults() {
|
||||
useVendorCompanyRoster.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
useVendorCompanyRoster.mockReturnValue({ data: undefined, isLoading: false, error: null });
|
||||
useVendorDeactivationImpact.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
|
|
@ -105,7 +80,7 @@ function setupDefaults() {
|
|||
}
|
||||
|
||||
describe("VendorsListPage", () => {
|
||||
it("renders prototype-aligned status and two row actions in both tabs", async () => {
|
||||
it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => {
|
||||
setupDefaults();
|
||||
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||
if (params.pageSize === 1) return result([], params.isActive ? 1 : 1);
|
||||
|
|
@ -116,20 +91,11 @@ describe("VendorsListPage", () => {
|
|||
|
||||
expect(screen.getByRole("heading", { name: "Vendors / Technicians" })).toBeInTheDocument();
|
||||
expect(screen.getByText("St. Louis, MO")).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();
|
||||
expect(screen.getByRole("button", { name: "Deactivate vendor" })).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();
|
||||
});
|
||||
|
||||
|
|
@ -148,18 +114,39 @@ describe("VendorsListPage", () => {
|
|||
|
||||
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||
|
||||
expect(screen.getByText(/25 active technicians/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/25 active vendor entries/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/3 active vendor entries/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the detail drawer when a row is clicked or activated by keyboard", async () => {
|
||||
setupDefaults();
|
||||
useVendorCompanyRoster.mockReturnValue({
|
||||
data: activeRoster,
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||
if (params.pageSize === 1) return result([], 1);
|
||||
|
|
@ -174,18 +161,11 @@ describe("VendorsListPage", () => {
|
|||
row.focus();
|
||||
await userEvent.keyboard("{Enter}");
|
||||
|
||||
expect(screen.getByRole("heading", { level: 2, name: "Adam Whyte" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { level: 2, name: "Gateway Plumbing" })).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,
|
||||
|
|
@ -209,8 +189,7 @@ describe("VendorsListPage", () => {
|
|||
|
||||
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
||||
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Deactivate vendor" }));
|
||||
|
||||
expect(
|
||||
screen.getByText(/cannot be deactivated because it still has open work orders/),
|
||||
|
|
@ -220,51 +199,9 @@ describe("VendorsListPage", () => {
|
|||
expect(mutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves inline edits when deactivation is cancelled", async () => {
|
||||
it("navigates to the vendor edit route when the table Edit control is clicked", async () => {
|
||||
setupDefaults();
|
||||
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");
|
||||
});
|
||||
|
||||
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(),
|
||||
});
|
||||
navigate.mockClear();
|
||||
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||
if (params.pageSize === 1) return result([], 1);
|
||||
return params.isActive ? result([activeVendor], 1) : result([], 0);
|
||||
|
|
@ -274,20 +211,16 @@ describe("VendorsListPage", () => {
|
|||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing");
|
||||
expect(navigate).toHaveBeenCalledWith("/vendors/edit/1");
|
||||
expect(
|
||||
screen.queryByRole("heading", { level: 2, name: "Gateway Plumbing" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens inline company edit when the row has no vendor id", async () => {
|
||||
it("navigates to the company route when editing a vendor without a 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);
|
||||
|
|
@ -297,7 +230,6 @@ describe("VendorsListPage", () => {
|
|||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing");
|
||||
expect(navigate).toHaveBeenCalledWith("/vendors/company/co-9");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { UpliftCell } from "@/app/(protected)/workorders/_components/list/table/cells/uplift-cell";
|
||||
import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const baseRow: WorkOrderTableRow = {
|
||||
id: 42,
|
||||
woNumber: "WO-42",
|
||||
site: "CHI-1042",
|
||||
type: "PM",
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Alex",
|
||||
scheduledOn: "2026-07-20",
|
||||
targetWeek: "2026-07-20",
|
||||
dayGroup: "2026-07-20",
|
||||
dayLabel: "Mon",
|
||||
location: "Chicago Loop",
|
||||
locationId: "loc-1",
|
||||
pm: "HVAC",
|
||||
serviceNotes: "",
|
||||
extraServices: [],
|
||||
vendorId: "1",
|
||||
company: "Vendor Co",
|
||||
tech: "Sam",
|
||||
techPhone: "",
|
||||
apptStart: "",
|
||||
apptEnd: "",
|
||||
apptTime: "",
|
||||
status: "Scheduled",
|
||||
docStatus: "No",
|
||||
pocName: "",
|
||||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
dueDate: "",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
isPastDue: false,
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 0,
|
||||
title: "",
|
||||
priority: "",
|
||||
primaryDispatchId: null,
|
||||
rowVersion: "v1",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
upliftSummary: { hasUplift: true, pendingCount: 1, primaryStatus: "pending", amount: 450 },
|
||||
};
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({
|
||||
useWorkOrderUplifts: () => ({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
status: "pending",
|
||||
amount: 450,
|
||||
notes: "Extra work",
|
||||
requestedAt: "2026-07-20T10:00:00Z",
|
||||
requestedById: "vendor-1",
|
||||
requestedByName: "Vendor",
|
||||
decidedAt: "",
|
||||
decidedByName: "",
|
||||
decisionNote: "",
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useCreateWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useCancelWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useRevokeWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
describe("UpliftCell affordances", () => {
|
||||
it("renders pending pill and opens handler when onOpen is provided", () => {
|
||||
const onOpen = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<UpliftCell summary={{ hasUplift: true, pendingCount: 1 }} onOpen={onOpen} />,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /1 pending/i }));
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows read-only dash when onOpen is omitted", () => {
|
||||
renderWithProviders(<UpliftCell summary={{ hasUplift: false, pendingCount: 0 }} />, {
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("—")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkOrderUpliftsDialog affordances", () => {
|
||||
it("shows create form for editable work orders", () => {
|
||||
renderWithProviders(<WorkOrderUpliftsDialog row={baseRow} open onClose={vi.fn()} />, {
|
||||
withAuth: true,
|
||||
});
|
||||
|
||||
expect(screen.getByRole("heading", { name: /uplifts — wo wo-42/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /create uplift/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /cancel pending/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides create and cancel actions when work order is completed", () => {
|
||||
renderWithProviders(
|
||||
<WorkOrderUpliftsDialog row={{ ...baseRow, status: "Completed" }} open onClose={vi.fn()} />,
|
||||
{ withAuth: true },
|
||||
);
|
||||
|
||||
expect(screen.getByText(/uplifts are read-only/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /create uplift/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /cancel pending/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -109,6 +109,7 @@ const canceledRow: WorkOrderTableRow = {
|
|||
rowVersion: "",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||
};
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiGet = vi.fn();
|
||||
const apiPost = vi.fn();
|
||||
|
||||
vi.mock("@/api/api", () => ({
|
||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||
apiPost: (...args: unknown[]) => apiPost(...args),
|
||||
}));
|
||||
|
||||
import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api";
|
||||
|
||||
describe("workOrderUpliftsApi", () => {
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
apiPost.mockReset();
|
||||
});
|
||||
|
||||
it("propagates a real API list failure instead of returning mock uplifts", async () => {
|
||||
const error = new Error("Unauthorized");
|
||||
apiGet.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(workOrderUpliftsApi.list(42)).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mapUpliftSummary,
|
||||
mapWorkOrderUplift,
|
||||
} from "@/domain/work-orders/mappers/work-order-uplift-mapper";
|
||||
import {
|
||||
getUpliftCellLabel,
|
||||
getUpliftStatusPillStyle,
|
||||
} from "@/domain/work-orders/utils/uplift-display-utils";
|
||||
|
||||
describe("mapUpliftSummary", () => {
|
||||
it("maps nested PascalCase upliftSummary from board row payload", () => {
|
||||
const result = mapUpliftSummary({
|
||||
id: 10,
|
||||
UpliftSummary: {
|
||||
HasUplift: true,
|
||||
PendingCount: 2,
|
||||
PrimaryStatus: "Pending",
|
||||
Amount: 500,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
hasUplift: true,
|
||||
pendingCount: 2,
|
||||
primaryStatus: "pending",
|
||||
amount: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives hasUplift from pendingUpliftCount when nested summary is absent", () => {
|
||||
const result = mapUpliftSummary({
|
||||
pendingUpliftCount: 1,
|
||||
});
|
||||
|
||||
expect(result.hasUplift).toBe(true);
|
||||
expect(result.pendingCount).toBe(1);
|
||||
});
|
||||
|
||||
it("normalizes auto-approved and canceled status aliases", () => {
|
||||
expect(mapUpliftSummary({ primaryStatus: "AutoApproved" }).primaryStatus).toBe("auto_approved");
|
||||
expect(mapWorkOrderUplift({ status: "Canceled" }).status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("maps requester identity for revoke ownership checks", () => {
|
||||
expect(mapWorkOrderUplift({ requestedById: "dispatcher-1" }).requestedById).toBe(
|
||||
"dispatcher-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uplift display utils", () => {
|
||||
it("builds pending pill label and style", () => {
|
||||
const summary = { hasUplift: true, pendingCount: 1, primaryStatus: "pending" as const };
|
||||
expect(getUpliftCellLabel(summary)).toBe("1 Pending");
|
||||
expect(getUpliftStatusPillStyle("pending")).toEqual({
|
||||
background: "var(--status-pending-bg)",
|
||||
color: "var(--status-pending-text)",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes amount in non-pending primary status label", () => {
|
||||
expect(
|
||||
getUpliftCellLabel({
|
||||
hasUplift: true,
|
||||
pendingCount: 0,
|
||||
primaryStatus: "approved",
|
||||
amount: 1200,
|
||||
}),
|
||||
).toBe("Approved $1,200");
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
isSlideOverDraftDirty,
|
||||
SLIDE_OVER_EDIT_KEYS,
|
||||
} from "@/domain/work-orders/utils/slide-over-draft";
|
||||
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
|
|
@ -49,6 +50,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
rowVersion: "1",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
upliftSummary: EMPTY_UPLIFT_SUMMARY,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import {
|
||||
|
|
@ -53,6 +54,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
rowVersion: "AQAAAAAAAAA=",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
upliftSummary: EMPTY_UPLIFT_SUMMARY,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canRevokeWorkOrderUplift,
|
||||
upliftRevokeRequiresReason,
|
||||
} from "@/domain/work-orders/utils/uplift-display-utils";
|
||||
|
||||
describe("canRevokeWorkOrderUplift", () => {
|
||||
it("allows a dispatcher to revoke their own auto-approved uplift", () => {
|
||||
expect(
|
||||
canRevokeWorkOrderUplift(
|
||||
{ status: "auto_approved", requestedById: "dispatcher-1" },
|
||||
"dispatcher-1",
|
||||
false,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a dispatcher revoking another user's auto-approved uplift", () => {
|
||||
expect(
|
||||
canRevokeWorkOrderUplift(
|
||||
{ status: "auto_approved", requestedById: "other-dispatcher" },
|
||||
"dispatcher-1",
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects admin revoking auto-approved uplift they did not request", () => {
|
||||
expect(
|
||||
canRevokeWorkOrderUplift(
|
||||
{ status: "auto_approved", requestedById: "dispatcher-1" },
|
||||
"admin-user",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows admin to revoke their own auto-approved uplift as the requester", () => {
|
||||
expect(
|
||||
canRevokeWorkOrderUplift(
|
||||
{ status: "auto_approved", requestedById: "admin-user" },
|
||||
"admin-user",
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows approved revoke only for admin", () => {
|
||||
const uplift = { status: "approved" as const, requestedById: "dispatcher-1" };
|
||||
expect(canRevokeWorkOrderUplift(uplift, "dispatcher-1", false)).toBe(false);
|
||||
expect(canRevokeWorkOrderUplift(uplift, "dispatcher-1", true)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects revoke for other statuses", () => {
|
||||
expect(canRevokeWorkOrderUplift({ status: "pending", requestedById: "u1" }, "u1", true)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(canRevokeWorkOrderUplift({ status: "rejected", requestedById: "u1" }, "u1", true)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("upliftRevokeRequiresReason", () => {
|
||||
it("requires reason only for approved uplifts", () => {
|
||||
expect(upliftRevokeRequiresReason("approved")).toBe(true);
|
||||
expect(upliftRevokeRequiresReason("auto_approved")).toBe(false);
|
||||
});
|
||||
});
|
||||
15
src/test/lib/auth/user-utils.test.ts
Normal file
15
src/test/lib/auth/user-utils.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { isAdminUser } from "@/lib/auth/user-utils";
|
||||
|
||||
describe("isAdminUser", () => {
|
||||
it("returns true when Admin appears in roles", () => {
|
||||
expect(isAdminUser("Admin")).toBe(true);
|
||||
expect(isAdminUser("Dispatcher,Admin")).toBe(true);
|
||||
expect(isAdminUser(" admin ")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-admin roles", () => {
|
||||
expect(isAdminUser("Dispatcher")).toBe(false);
|
||||
expect(isAdminUser(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
46
tmp/pr-descriptions/pr3-uplifts-column-tab.md
Normal file
46
tmp/pr-descriptions/pr3-uplifts-column-tab.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
## Jira
|
||||
|
||||
- [SH-196](https://luby-us.atlassian.net/browse/SH-196)
|
||||
|
||||
## QA residual
|
||||
|
||||
- Conscious adaptation: **dialog** vs design slide-over tab (no slide-over on this `dev`).
|
||||
- P0 done: admin revoke of `approved` with required reason.
|
||||
- P1: `+ Add` / Manage + attachments polish.
|
||||
|
||||
## Summary
|
||||
|
||||
Adds the **UPLIFT** column to the work orders board table (after COMP DOC) with a status pill that opens a **uplifts dialog** (not a slide-over tab — board currently navigates to `/workorders/:id` on row detail).
|
||||
|
||||
- Maps `upliftSummary` on each board row: `{ hasUplift, pendingCount, primaryStatus?, amount? }`
|
||||
- Dialog: list uplifts, create (amount/notes + attachments UI stub), cancel pending, revoke `auto_approved` (optional reason) and `approved` for **admin** (required reason, SH-196); read-only when status is Completed/Canceled
|
||||
- WO-scoped API client + query keys under `workorders/{id}/uplifts`
|
||||
- DEV in-memory mock fallback when API calls fail
|
||||
|
||||
## API / assumptions
|
||||
|
||||
| Item | Assumption |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| UI surface | **Dialog** from UPLIFT column click — design ref uses slide-over tab, but `dev` has no slide-over; detail opens via route navigation |
|
||||
| Row field | `upliftSummary` on GET `/board` and `/board/search` rows (see `docs/work-orders/pending-be-contract.md`) |
|
||||
| Endpoints | `GET/POST workorders/{id}/uplifts`, `POST …/uplifts/{id}/cancel\|revoke` — distinct from NTE queue `uplifts` API |
|
||||
| Attachments | Create form shows disabled attachments field until BE accepts files |
|
||||
| DEV mock | When `import.meta.env.DEV` and API throws, `work-order-uplifts-mock-store` serves list/create/cancel/revoke |
|
||||
|
||||
## Out of scope (later PRs)
|
||||
|
||||
- Closability / Comp Doc pending gate (PR4)
|
||||
- Advanced filter uplift facets (PR5)
|
||||
- Vendor Portal NTE queue
|
||||
|
||||
## Test plan
|
||||
|
||||
- [ ] Board row shows UPLIFT pill after COMP DOC (pending count or primary status + amount)
|
||||
- [ ] Click UPLIFT opens dialog; Completed/Canceled rows are read-only
|
||||
- [ ] Create uplift (DEV mock) updates list; cancel pending / revoke auto_approved work
|
||||
- [ ] Admin can revoke `approved` uplifts with required reason; non-admin cannot
|
||||
- [ ] `npm run verify` green
|
||||
|
||||
## Design ref
|
||||
|
||||
`seahaven-desing` — `WorkOrders.tsx` `UpliftCell` (~L4669)
|
||||
Loading…
Add table
Reference in a new issue