Merge branch 'dev' into fix/ab/sh-182-remove-trade-arrows
4
.github/workflows/ci.yaml
vendored
|
|
@ -42,7 +42,7 @@ jobs:
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run verify
|
- run: npm run verify
|
||||||
|
|
||||||
vendor-visual-regression:
|
visual-regression:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: mcr.microsoft.com/playwright:v1.61.1-noble
|
container: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -57,7 +57,7 @@ jobs:
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: vendor-visual-regression-diffs
|
name: visual-regression-diffs
|
||||||
path: |
|
path: |
|
||||||
test-results/visual
|
test-results/visual
|
||||||
playwright-report-visual
|
playwright-report-visual
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 193 KiB |
137
e2e/work-orders/advanced-filters.spec.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||||
|
|
||||||
|
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
|
||||||
|
|
||||||
|
const TEST_USER = {
|
||||||
|
token: "wo-adv-filters-e2e-token",
|
||||||
|
expiration: "2030-01-01T00:00:00.000Z",
|
||||||
|
email: "admin@seahavenind.com",
|
||||||
|
userRoles: "Admin",
|
||||||
|
phoneNumber: "5551234567",
|
||||||
|
fullname: "Work Order E2E Admin",
|
||||||
|
id: "1",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||||
|
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function boardRow(id: number, woNumber: string, scheduledDate: string | null) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
woNumber,
|
||||||
|
workOrderType: 2,
|
||||||
|
siteCode: woNumber,
|
||||||
|
lifecycleStatusLabel: "Scheduled",
|
||||||
|
scheduledDate,
|
||||||
|
dispatcherId: "1",
|
||||||
|
dispatcherName: "Pat Dispatcher",
|
||||||
|
pm: "HVAC",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockWorkOrderApis(page: Page, searchUrls: string[]) {
|
||||||
|
await page.route("**/api/**", (route) => {
|
||||||
|
const pathname = new URL(route.request().url()).pathname;
|
||||||
|
if (!pathname.startsWith("/api/")) {
|
||||||
|
return route.fallback();
|
||||||
|
}
|
||||||
|
if (pathname.includes("/workorders/board") || pathname.includes("DropdownOptions")) {
|
||||||
|
return route.fallback();
|
||||||
|
}
|
||||||
|
return fulfillJson(route, { data: [] });
|
||||||
|
});
|
||||||
|
await page.route("**/api/**/DropdownOptions/ByCategory/**", (route) => {
|
||||||
|
const pathname = new URL(route.request().url()).pathname;
|
||||||
|
if (pathname.toLowerCase().includes("problem")) {
|
||||||
|
return fulfillJson(route, { data: [{ id: 1, value: "HVAC", category: "Problem" }] });
|
||||||
|
}
|
||||||
|
return fulfillJson(route, { data: [] });
|
||||||
|
});
|
||||||
|
await page.route("**/api/workorders/board*", (route) => {
|
||||||
|
if (route.request().url().includes("board/search")) {
|
||||||
|
return route.fallback();
|
||||||
|
}
|
||||||
|
return fulfillJson(route, {
|
||||||
|
weekStart: "2026-08-17",
|
||||||
|
weekEnd: "2026-08-21",
|
||||||
|
counts: { returned: 1, total: 1 },
|
||||||
|
unscheduled: [],
|
||||||
|
scheduled: [boardRow(1, "ADV-WO", "2026-08-19")],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/workorders/board/search*", (route) => {
|
||||||
|
searchUrls.push(route.request().url());
|
||||||
|
return fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signInAndOpenWorkOrders(page: Page) {
|
||||||
|
await page.clock.install({ time: new Date(FROZEN_NOW) });
|
||||||
|
await page.clock.resume();
|
||||||
|
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyAdvancedFilters(page: Page) {
|
||||||
|
await page.getByRole("button", { name: "Advanced Filters" }).click();
|
||||||
|
const sheet = page.getByRole("dialog", { name: "Advanced Filters" });
|
||||||
|
await expect(sheet).toBeVisible();
|
||||||
|
return sheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Work Orders advanced filters", () => {
|
||||||
|
test("sends carriedOver on board/search and shows empty copy", async ({ page }) => {
|
||||||
|
const searchUrls: string[] = [];
|
||||||
|
await signInAndOpenWorkOrders(page);
|
||||||
|
await mockWorkOrderApis(page, searchUrls);
|
||||||
|
|
||||||
|
await page.goto("/workorders");
|
||||||
|
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sheet = await applyAdvancedFilters(page);
|
||||||
|
await sheet.getByRole("button", { name: /Indicators/i }).click();
|
||||||
|
await sheet.getByRole("button", { name: /Carried Over ≥ 2/i }).click();
|
||||||
|
await sheet.getByRole("button", { name: /apply filters/i }).click();
|
||||||
|
|
||||||
|
await expect.poll(() => searchUrls.some((url) => url.includes("carriedOver=true"))).toBe(true);
|
||||||
|
await expect(page.getByText("No work orders match these filters")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sends combined advanced-filter facets on the same board/search request", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const searchUrls: string[] = [];
|
||||||
|
await signInAndOpenWorkOrders(page);
|
||||||
|
await mockWorkOrderApis(page, searchUrls);
|
||||||
|
|
||||||
|
await page.goto("/workorders");
|
||||||
|
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sheet = await applyAdvancedFilters(page);
|
||||||
|
await sheet.getByRole("button", { name: /Severity/i }).click();
|
||||||
|
await sheet.getByRole("button", { name: /SEV 1/i }).click();
|
||||||
|
await sheet.getByRole("button", { name: /Indicators/i }).click();
|
||||||
|
await sheet.getByRole("button", { name: /^Add-On$/i }).click();
|
||||||
|
await sheet.getByRole("button", { name: /^Service/i }).click();
|
||||||
|
await sheet.getByText("HVAC").click();
|
||||||
|
await sheet.getByRole("button", { name: /Completion doc/i }).click();
|
||||||
|
await sheet.getByRole("button", { name: /^Pending$/i }).click();
|
||||||
|
await sheet.getByRole("button", { name: /apply filters/i }).click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => {
|
||||||
|
const url = searchUrls[searchUrls.length - 1] ?? "";
|
||||||
|
return (
|
||||||
|
url.includes("severities=1") &&
|
||||||
|
url.includes("addOn=true") &&
|
||||||
|
url.includes("pmTypes=HVAC") &&
|
||||||
|
(url.includes("docStatuses=2") || url.includes("DocStatuses=2"))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
252
e2e/work-orders/work-orders.visual.spec.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||||
|
|
||||||
|
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
|
||||||
|
const WEEK_START = "2026-08-17";
|
||||||
|
const WEEK_END = "2026-08-21";
|
||||||
|
|
||||||
|
const TEST_USER = {
|
||||||
|
token: "wo-visual-token",
|
||||||
|
expiration: "2030-01-01T00:00:00.000Z",
|
||||||
|
email: "admin@seahavenind.com",
|
||||||
|
userRoles: "Admin",
|
||||||
|
phoneNumber: "5551234567",
|
||||||
|
fullname: "Work Order Visual Admin",
|
||||||
|
id: "1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DISPATCHERS = [{ id: "1", name: "Pat Dispatcher" }];
|
||||||
|
|
||||||
|
const LOCATIONS = [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
name: "STL-01 · St. Louis, MO",
|
||||||
|
siteCode: "STL-01",
|
||||||
|
city: "St. Louis",
|
||||||
|
state: "MO",
|
||||||
|
code: "STL-01",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const VENDORS = [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Adam Whyte",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
address: "1 Market St",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const scheduledRow = {
|
||||||
|
id: 1,
|
||||||
|
woNumber: "WO-501",
|
||||||
|
workOrderType: 2,
|
||||||
|
siteCode: "STL-01",
|
||||||
|
locationName: "St. Louis HQ",
|
||||||
|
locationId: 10,
|
||||||
|
lifecycleStatusLabel: "Scheduled",
|
||||||
|
scheduledDate: "2026-08-19",
|
||||||
|
dispatcherId: "1",
|
||||||
|
dispatcherName: "Pat Dispatcher",
|
||||||
|
pm: "HVAC",
|
||||||
|
vendorId: 101,
|
||||||
|
vendorName: "Gateway Plumbing",
|
||||||
|
techName: "Adam Whyte",
|
||||||
|
title: "Quarterly HVAC PM",
|
||||||
|
dueDate: "2026-08-21",
|
||||||
|
rowVersion: "rv-visual",
|
||||||
|
};
|
||||||
|
|
||||||
|
const unassignedRow = {
|
||||||
|
...scheduledRow,
|
||||||
|
id: 2,
|
||||||
|
woNumber: "WO-502",
|
||||||
|
scheduledDate: null,
|
||||||
|
lifecycleStatusLabel: "Pending",
|
||||||
|
title: "Boiler leak follow-up",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||||
|
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBoardList(pathname: string) {
|
||||||
|
return pathname.endsWith("/workorders/board") || pathname.endsWith("/api/workorders/board");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBoardSearch(pathname: string) {
|
||||||
|
return pathname.includes("/workorders/board/search");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBoardDetail(pathname: string) {
|
||||||
|
return /\/workorders\/\d+\/detail$/.test(pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockWorkOrderApi(page: Page, mode: "default" | "empty" | "error" = "default") {
|
||||||
|
await page.clock.setFixedTime(new Date(FROZEN_NOW));
|
||||||
|
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||||
|
|
||||||
|
await page.route("**/api/**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const pathname = url.pathname;
|
||||||
|
|
||||||
|
if (!pathname.startsWith("/api/")) {
|
||||||
|
await route.continue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBoardSearch(pathname)) {
|
||||||
|
const items = mode === "default" ? [unassignedRow] : [];
|
||||||
|
await fulfillJson(route, { items, totalCount: items.length, page: 0, pageSize: 50 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBoardList(pathname)) {
|
||||||
|
if (mode === "error") {
|
||||||
|
await fulfillJson(route, { message: "Work order board unavailable" }, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scheduled = mode === "empty" ? [] : [scheduledRow];
|
||||||
|
const unscheduled = mode === "empty" ? [] : [unassignedRow];
|
||||||
|
await fulfillJson(route, {
|
||||||
|
weekStart: WEEK_START,
|
||||||
|
weekEnd: WEEK_END,
|
||||||
|
counts: { returned: scheduled.length + unscheduled.length, total: scheduled.length + 1 },
|
||||||
|
unscheduled,
|
||||||
|
scheduled,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBoardDetail(pathname)) {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
info: scheduledRow,
|
||||||
|
comments: [],
|
||||||
|
audit: [],
|
||||||
|
media: [],
|
||||||
|
completion: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.includes("/workorders/lookups/dispatchers")) {
|
||||||
|
await fulfillJson(route, { data: DISPATCHERS });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.includes("/Contact/GetLocations") || pathname.includes("/locations/sites")) {
|
||||||
|
await fulfillJson(route, { data: LOCATIONS });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.includes("/vendors/Dropdown") || pathname.endsWith("/vendors/dropdown")) {
|
||||||
|
await fulfillJson(route, { data: VENDORS });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.includes("/DropdownOptions/ByCategory/Problem")) {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
data: [
|
||||||
|
{ id: 1, value: "HVAC", category: "Problem" },
|
||||||
|
{ id: 2, value: "Plumbing", category: "Problem" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.includes("/api/User")) {
|
||||||
|
await fulfillJson(route, { data: DISPATCHERS });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fulfillJson(route, { data: [] });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openWorkOrderPage(page: Page, mode: "default" | "empty" | "error" = "default") {
|
||||||
|
await mockWorkOrderApi(page, mode);
|
||||||
|
await page.goto("/workorders");
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
await page.addStyleTag({
|
||||||
|
content: `
|
||||||
|
*, *::before, *::after { transition: none !important; animation: none !important; }
|
||||||
|
.MuiDrawer-paper { transform: none !important; }
|
||||||
|
.Toastify__toast-container { display: none !important; }
|
||||||
|
.tsqd-parent-container { display: none !important; }
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectStableScreenshot(page: Page, name: string) {
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) =>
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.screenshot({ animations: "disabled", caret: "hide" });
|
||||||
|
await expect(page).toHaveScreenshot(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Work Orders deterministic pixel regression", () => {
|
||||||
|
test("list", async ({ page }) => {
|
||||||
|
await openWorkOrderPage(page);
|
||||||
|
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible();
|
||||||
|
await expect(page.getByText("WO-501").first()).toBeVisible();
|
||||||
|
await expectStableScreenshot(page, "wo-list.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filters", async ({ page }) => {
|
||||||
|
await openWorkOrderPage(page);
|
||||||
|
await page.getByRole("button", { name: "Advanced Filters" }).click();
|
||||||
|
await expect(page.getByRole("dialog", { name: "Advanced Filters" })).toBeVisible();
|
||||||
|
await expectStableScreenshot(page, "wo-filters.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("new", async ({ page }) => {
|
||||||
|
await openWorkOrderPage(page);
|
||||||
|
await page.getByRole("button", { name: "New WO" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible();
|
||||||
|
await expectStableScreenshot(page, "wo-new.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detail", async ({ page }) => {
|
||||||
|
await openWorkOrderPage(page);
|
||||||
|
const row = page.locator("#wo-row-1");
|
||||||
|
await row.hover();
|
||||||
|
await row.getByRole("button", { name: "View details" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "#WO-501" })).toBeVisible();
|
||||||
|
await expectStableScreenshot(page, "wo-detail.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty", async ({ page }) => {
|
||||||
|
await openWorkOrderPage(page, "empty");
|
||||||
|
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible();
|
||||||
|
await page.getByLabel("Search work orders").fill("zz");
|
||||||
|
await expect(page.getByText("No work orders match your search")).toBeVisible();
|
||||||
|
await expectStableScreenshot(page, "wo-empty.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("error", async ({ page }) => {
|
||||||
|
await openWorkOrderPage(page, "error");
|
||||||
|
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible();
|
||||||
|
const alert = page.getByRole("main").getByRole("alert");
|
||||||
|
await expect(alert).toBeVisible();
|
||||||
|
await expect(alert).toContainText(/server error/i);
|
||||||
|
await expectStableScreenshot(page, "wo-error.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await openWorkOrderPage(page);
|
||||||
|
await expect(page.getByText("WO-501").first()).toBeVisible();
|
||||||
|
await expectStableScreenshot(page, "wo-mobile.png");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Toggle navigation" }).click();
|
||||||
|
await expect(page.getByRole("navigation", { name: "Primary navigation" })).toBeVisible();
|
||||||
|
await expectStableScreenshot(page, "wo-mobile-navigation.png");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
|
import { useState } from "react";
|
||||||
import { Box } from "@mui/material";
|
import { Box } from "@mui/material";
|
||||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||||
import { DetailPageSkeleton } from "./_components/loading/detail-page-skeleton";
|
import { DetailPageSkeleton } from "./_components/loading/detail-page-skeleton";
|
||||||
|
|
@ -11,17 +12,23 @@ import { WorkOrderViewAccordions } from "./_components/view/work-order-view-acco
|
||||||
import { WorkOrderViewDetails } from "./_components/view/work-order-view-details";
|
import { WorkOrderViewDetails } from "./_components/view/work-order-view-details";
|
||||||
import { WorkOrderViewHeader } from "./_components/view/work-order-view-header";
|
import { WorkOrderViewHeader } from "./_components/view/work-order-view-header";
|
||||||
import { useWorkOrderViewPage } from "./_hooks/use-work-order-view-page";
|
import { useWorkOrderViewPage } from "./_hooks/use-work-order-view-page";
|
||||||
|
import { WorkOrderCompleteDialog } from "./_components/list/work-order-complete-dialog";
|
||||||
import {
|
import {
|
||||||
detailToClosabilityInput,
|
detailToClosabilityInput,
|
||||||
getClosabilityGaps,
|
getClosabilityGaps,
|
||||||
isCompletionStatusLabel,
|
isCompletionStatusLabel,
|
||||||
} from "@/domain/work-orders/utils/wo-closability";
|
} from "@/domain/work-orders/utils/wo-closability";
|
||||||
|
import {
|
||||||
|
isWorkOrderCoreLocked,
|
||||||
|
isWorkOrderFullyLocked,
|
||||||
|
} from "@/domain/work-orders/utils/work-order-lock";
|
||||||
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order";
|
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
export default function WorkOrderViewPage() {
|
export default function WorkOrderViewPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const workOrderId = id ?? "";
|
const workOrderId = id ?? "";
|
||||||
const view = useWorkOrderViewPage(workOrderId);
|
const view = useWorkOrderViewPage(workOrderId);
|
||||||
|
const [confirmComplete, setConfirmComplete] = useState(false);
|
||||||
|
|
||||||
if (view.isLoading && !view.wo) {
|
if (view.isLoading && !view.wo) {
|
||||||
return <DetailPageSkeleton variant="view" />;
|
return <DetailPageSkeleton variant="view" />;
|
||||||
|
|
@ -39,8 +46,10 @@ export default function WorkOrderViewPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const wo = view.wo;
|
const wo = view.wo;
|
||||||
|
const isLocked = isWorkOrderCoreLocked(wo.status) || isWorkOrderFullyLocked(wo.status);
|
||||||
|
|
||||||
const handleStatusChange = (status: string) => {
|
const handleStatusChange = (status: string) => {
|
||||||
|
if (isLocked) return;
|
||||||
const isCompletionStatus = isCompletionStatusLabel(status);
|
const isCompletionStatus = isCompletionStatusLabel(status);
|
||||||
if (
|
if (
|
||||||
isCompletionStatus &&
|
isCompletionStatus &&
|
||||||
|
|
@ -48,6 +57,10 @@ export default function WorkOrderViewPage() {
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isCompletionStatus) {
|
||||||
|
setConfirmComplete(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
view.statusMutation.mutate(status as WorkOrderStatus);
|
view.statusMutation.mutate(status as WorkOrderStatus);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -61,6 +74,7 @@ export default function WorkOrderViewPage() {
|
||||||
onStatusAnchorChange={view.setStatusAnchor}
|
onStatusAnchorChange={view.setStatusAnchor}
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
onOpenDispatch={() => view.setDispatchOpen(true)}
|
onOpenDispatch={() => view.setDispatchOpen(true)}
|
||||||
|
readOnly={isLocked}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WorkOrderViewDetails
|
<WorkOrderViewDetails
|
||||||
|
|
@ -68,14 +82,22 @@ export default function WorkOrderViewPage() {
|
||||||
users={view.users}
|
users={view.users}
|
||||||
assignAnchor={view.assignAnchor}
|
assignAnchor={view.assignAnchor}
|
||||||
onAssignAnchorChange={view.setAssignAnchor}
|
onAssignAnchorChange={view.setAssignAnchor}
|
||||||
onAssign={(userId, userName) => view.assignmentMutation.mutate({ userId, userName })}
|
onAssign={(userId, userName) => {
|
||||||
onUnassign={() => view.assignmentMutation.mutate({ userId: "", userName: "" })}
|
if (isLocked) return;
|
||||||
|
view.assignmentMutation.mutate({ userId, userName });
|
||||||
|
}}
|
||||||
|
onUnassign={() => {
|
||||||
|
if (isLocked) return;
|
||||||
|
view.assignmentMutation.mutate({ userId: "", userName: "" });
|
||||||
|
}}
|
||||||
|
readOnly={isLocked}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WorkOrderViewAccordions wo={view.wo} />
|
<WorkOrderViewAccordions wo={view.wo} />
|
||||||
|
|
||||||
<WorkOrderDispatchesAccordion
|
<WorkOrderDispatchesAccordion
|
||||||
wo={view.wo}
|
wo={view.wo}
|
||||||
|
vendorViewOnly={isLocked}
|
||||||
onOpenDispatchDetail={view.setDispatchDetailId}
|
onOpenDispatchDetail={view.setDispatchDetailId}
|
||||||
onOpenVendorEditor={view.openVendorEditor}
|
onOpenVendorEditor={view.openVendorEditor}
|
||||||
/>
|
/>
|
||||||
|
|
@ -110,12 +132,24 @@ export default function WorkOrderViewPage() {
|
||||||
loading={view.vendorEditorLoading}
|
loading={view.vendorEditorLoading}
|
||||||
error={view.vendorEditorError}
|
error={view.vendorEditorError}
|
||||||
isSaving={view.vendorMutation.isPending}
|
isSaving={view.vendorMutation.isPending}
|
||||||
|
viewOnly={isLocked}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
if (!view.vendorMutation.isPending) view.setVendorEditor(null);
|
if (!view.vendorMutation.isPending) view.setVendorEditor(null);
|
||||||
}}
|
}}
|
||||||
onSave={view.saveVendorEditor}
|
onSave={view.saveVendorEditor}
|
||||||
onEditorChange={view.setVendorEditor}
|
onEditorChange={view.setVendorEditor}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<WorkOrderCompleteDialog
|
||||||
|
open={confirmComplete}
|
||||||
|
isPending={view.statusMutation.isPending}
|
||||||
|
onDialogClose={() => setConfirmComplete(false)}
|
||||||
|
onKeepOpen={() => setConfirmComplete(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
view.statusMutation.mutate("Done");
|
||||||
|
setConfirmComplete(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
type SlideOverFooterProps = {
|
type SlideOverFooterProps = {
|
||||||
isCancelled: boolean;
|
isCancelled: boolean;
|
||||||
|
isCompleted?: boolean;
|
||||||
editing: boolean;
|
editing: boolean;
|
||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
showUnsaved: boolean;
|
showUnsaved: boolean;
|
||||||
|
|
@ -19,6 +20,7 @@ type SlideOverFooterProps = {
|
||||||
|
|
||||||
export function SlideOverFooter({
|
export function SlideOverFooter({
|
||||||
isCancelled,
|
isCancelled,
|
||||||
|
isCompleted = false,
|
||||||
editing,
|
editing,
|
||||||
isDirty,
|
isDirty,
|
||||||
showUnsaved,
|
showUnsaved,
|
||||||
|
|
@ -31,6 +33,27 @@ export function SlideOverFooter({
|
||||||
onDiscard,
|
onDiscard,
|
||||||
onKeepEditing,
|
onKeepEditing,
|
||||||
}: SlideOverFooterProps) {
|
}: SlideOverFooterProps) {
|
||||||
|
if (isCancelled) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center gap-2 px-4 py-2.5"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ban size={14} style={{ color: "var(--muted-foreground)" }} />
|
||||||
|
<Text as="p" className="text-[12px]" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
This work order has been cancelled and is read-only.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCompleted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (showUnsaved) {
|
if (showUnsaved) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -68,23 +91,6 @@ export function SlideOverFooter({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCancelled) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex shrink-0 items-center gap-2 px-4 py-2.5"
|
|
||||||
style={{
|
|
||||||
background: "var(--color-bg-muted)",
|
|
||||||
borderTop: "1px solid var(--color-border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ban size={14} style={{ color: "var(--muted-foreground)" }} />
|
|
||||||
<Text as="p" className="text-[12px]" style={{ color: "var(--muted-foreground)" }}>
|
|
||||||
This work order has been cancelled and is read-only.
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex shrink-0 items-center justify-between gap-3 px-5 py-3"
|
className="flex shrink-0 items-center justify-between gap-3 px-5 py-3"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
|
import { SiteCell } from "@/app/(protected)/workorders/_components/list/table/cells/site-cell";
|
||||||
|
import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
|
||||||
|
import { VendorCell } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-cell";
|
||||||
import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
|
import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
|
||||||
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||||
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
|
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
|
||||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
import type {
|
||||||
|
LocationOption,
|
||||||
|
VendorDropdownItem,
|
||||||
|
WorkOrderUser,
|
||||||
|
} from "@/domain/work-orders/types/work-order";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils";
|
import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
@ -13,13 +20,21 @@ import { SlideOverInfoTabVendorField } from "./slide-over-info-tab-vendor-field"
|
||||||
type SlideOverInfoTabDisplayViewProps = {
|
type SlideOverInfoTabDisplayViewProps = {
|
||||||
source: WorkOrderTableRow;
|
source: WorkOrderTableRow;
|
||||||
isCancelled: boolean;
|
isCancelled: boolean;
|
||||||
|
isCompleted?: boolean;
|
||||||
dispatcher?: WorkOrderUser;
|
dispatcher?: WorkOrderUser;
|
||||||
|
sites: LocationOption[];
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
pmTypes: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SlideOverInfoTabDisplayView({
|
export function SlideOverInfoTabDisplayView({
|
||||||
source,
|
source,
|
||||||
isCancelled,
|
isCancelled,
|
||||||
|
isCompleted = false,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
|
sites,
|
||||||
|
vendors,
|
||||||
|
pmTypes,
|
||||||
}: SlideOverInfoTabDisplayViewProps) {
|
}: SlideOverInfoTabDisplayViewProps) {
|
||||||
const appt = parseApptTime(source.apptTime);
|
const appt = parseApptTime(source.apptTime);
|
||||||
const apptDisplay = appt.start ? formatApptRange(appt.start, appt.end) : source.apptTime || "—";
|
const apptDisplay = appt.start ? formatApptRange(appt.start, appt.end) : source.apptTime || "—";
|
||||||
|
|
@ -30,9 +45,27 @@ export function SlideOverInfoTabDisplayView({
|
||||||
<span className="font-mono text-[12.5px] font-semibold">{source.woNumber || "—"}</span>
|
<span className="font-mono text-[12.5px] font-semibold">{source.woNumber || "—"}</span>
|
||||||
</SlideOverInfoRow>
|
</SlideOverInfoRow>
|
||||||
<SlideOverInfoRow label="Site">
|
<SlideOverInfoRow label="Site">
|
||||||
<span className="font-mono text-[12.5px] font-bold" style={{ color: "var(--foreground)" }}>
|
{isCompleted ? (
|
||||||
{source.site || "—"}
|
<SiteCell
|
||||||
</span>
|
value={source.site}
|
||||||
|
location={source.location}
|
||||||
|
locationId={source.locationId}
|
||||||
|
pocName={source.pocName}
|
||||||
|
pocPhone={source.pocPhone}
|
||||||
|
pocNotes={source.pocNotes}
|
||||||
|
additionalContacts={source.additionalContacts}
|
||||||
|
sites={sites}
|
||||||
|
viewOnly
|
||||||
|
onSave={() => undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="font-mono text-[12.5px] font-bold"
|
||||||
|
style={{ color: "var(--foreground)" }}
|
||||||
|
>
|
||||||
|
{source.site || "—"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</SlideOverInfoRow>
|
</SlideOverInfoRow>
|
||||||
<SlideOverInfoRow label="Type of WO">
|
<SlideOverInfoRow label="Type of WO">
|
||||||
{source.type ? (
|
{source.type ? (
|
||||||
|
|
@ -61,7 +94,17 @@ export function SlideOverInfoTabDisplayView({
|
||||||
<span className="text-[12.5px]">{apptDisplay}</span>
|
<span className="text-[12.5px]">{apptDisplay}</span>
|
||||||
</SlideOverInfoRow>
|
</SlideOverInfoRow>
|
||||||
<SlideOverInfoRow label="Service">
|
<SlideOverInfoRow label="Service">
|
||||||
{source.pm ? (
|
{isCompleted ? (
|
||||||
|
<PMTypeCell
|
||||||
|
value={source.pm}
|
||||||
|
pmTypes={pmTypes}
|
||||||
|
serviceNotes={source.serviceNotes}
|
||||||
|
extraServices={source.extraServices}
|
||||||
|
woNumber={source.woNumber}
|
||||||
|
viewOnly
|
||||||
|
onSave={() => undefined}
|
||||||
|
/>
|
||||||
|
) : source.pm ? (
|
||||||
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
|
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
|
||||||
<PmTypeIcon pmType={source.pm} size={14} />
|
<PmTypeIcon pmType={source.pm} size={14} />
|
||||||
{source.pm}
|
{source.pm}
|
||||||
|
|
@ -71,7 +114,19 @@ export function SlideOverInfoTabDisplayView({
|
||||||
)}
|
)}
|
||||||
</SlideOverInfoRow>
|
</SlideOverInfoRow>
|
||||||
<SlideOverInfoRow label="Vendor">
|
<SlideOverInfoRow label="Vendor">
|
||||||
<SlideOverInfoTabVendorField source={source} />
|
{isCompleted ? (
|
||||||
|
<VendorCell
|
||||||
|
vendorId={source.vendorId}
|
||||||
|
company={source.company}
|
||||||
|
tech={source.tech}
|
||||||
|
techPhone={source.techPhone}
|
||||||
|
vendors={vendors}
|
||||||
|
viewOnly
|
||||||
|
onSave={() => undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SlideOverInfoTabVendorField source={source} />
|
||||||
|
)}
|
||||||
</SlideOverInfoRow>
|
</SlideOverInfoRow>
|
||||||
<SlideOverInfoRow label="Status">
|
<SlideOverInfoRow label="Status">
|
||||||
<SlideOverInfoTabStatusField source={source} />
|
<SlideOverInfoTabStatusField source={source} />
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ type SlideOverInfoTabEditViewProps = {
|
||||||
allOrders: WorkOrderTableRow[];
|
allOrders: WorkOrderTableRow[];
|
||||||
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
||||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
|
onRequestComplete?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SlideOverInfoTabEditView({
|
export function SlideOverInfoTabEditView({
|
||||||
|
|
@ -38,6 +39,7 @@ export function SlideOverInfoTabEditView({
|
||||||
allOrders,
|
allOrders,
|
||||||
onDraftChange,
|
onDraftChange,
|
||||||
onDuplicateFound,
|
onDuplicateFound,
|
||||||
|
onRequestComplete,
|
||||||
}: SlideOverInfoTabEditViewProps) {
|
}: SlideOverInfoTabEditViewProps) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -149,6 +151,7 @@ export function SlideOverInfoTabEditView({
|
||||||
wo={draft}
|
wo={draft}
|
||||||
onChangeStatus={(status: WizardWOStatus) => onDraftChange({ status })}
|
onChangeStatus={(status: WizardWOStatus) => onDraftChange({ status })}
|
||||||
onOpenSlideOver={() => {}}
|
onOpenSlideOver={() => {}}
|
||||||
|
onRequestComplete={onRequestComplete}
|
||||||
/>
|
/>
|
||||||
</SlideOverInfoRow>
|
</SlideOverInfoRow>
|
||||||
<SlideOverInfoRow label="Comp Doc">
|
<SlideOverInfoRow label="Comp Doc">
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ type SlideOverInfoTabProps = {
|
||||||
draft: WorkOrderTableRow;
|
draft: WorkOrderTableRow;
|
||||||
editing: boolean;
|
editing: boolean;
|
||||||
isCancelled: boolean;
|
isCancelled: boolean;
|
||||||
|
isCompleted?: boolean;
|
||||||
users: WorkOrderUser[];
|
users: WorkOrderUser[];
|
||||||
sites: LocationOption[];
|
sites: LocationOption[];
|
||||||
vendors: VendorDropdownItem[];
|
vendors: VendorDropdownItem[];
|
||||||
|
|
@ -19,6 +20,7 @@ type SlideOverInfoTabProps = {
|
||||||
allOrders: WorkOrderTableRow[];
|
allOrders: WorkOrderTableRow[];
|
||||||
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
||||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
|
onRequestComplete?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SlideOverInfoTab({
|
export function SlideOverInfoTab({
|
||||||
|
|
@ -26,6 +28,7 @@ export function SlideOverInfoTab({
|
||||||
draft,
|
draft,
|
||||||
editing,
|
editing,
|
||||||
isCancelled,
|
isCancelled,
|
||||||
|
isCompleted = false,
|
||||||
users,
|
users,
|
||||||
sites,
|
sites,
|
||||||
vendors,
|
vendors,
|
||||||
|
|
@ -33,8 +36,9 @@ export function SlideOverInfoTab({
|
||||||
allOrders,
|
allOrders,
|
||||||
onDraftChange,
|
onDraftChange,
|
||||||
onDuplicateFound,
|
onDuplicateFound,
|
||||||
|
onRequestComplete,
|
||||||
}: SlideOverInfoTabProps) {
|
}: SlideOverInfoTabProps) {
|
||||||
const isEditing = editing && !isCancelled;
|
const isEditing = editing && !isCancelled && !isCompleted;
|
||||||
const source = isEditing ? draft : row;
|
const source = isEditing ? draft : row;
|
||||||
const dispatcher = users.find((u) => String(u.id) === source.dispatcherId);
|
const dispatcher = users.find((u) => String(u.id) === source.dispatcherId);
|
||||||
|
|
||||||
|
|
@ -49,6 +53,7 @@ export function SlideOverInfoTab({
|
||||||
allOrders={allOrders}
|
allOrders={allOrders}
|
||||||
onDraftChange={onDraftChange}
|
onDraftChange={onDraftChange}
|
||||||
onDuplicateFound={onDuplicateFound}
|
onDuplicateFound={onDuplicateFound}
|
||||||
|
onRequestComplete={onRequestComplete}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -57,7 +62,11 @@ export function SlideOverInfoTab({
|
||||||
<SlideOverInfoTabDisplayView
|
<SlideOverInfoTabDisplayView
|
||||||
source={source}
|
source={source}
|
||||||
isCancelled={isCancelled}
|
isCancelled={isCancelled}
|
||||||
|
isCompleted={isCompleted}
|
||||||
dispatcher={dispatcher}
|
dispatcher={dispatcher}
|
||||||
|
sites={sites}
|
||||||
|
vendors={vendors}
|
||||||
|
pmTypes={pmTypes}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,17 @@ import {
|
||||||
buildSlideOverPatch,
|
buildSlideOverPatch,
|
||||||
isSlideOverDraftDirty,
|
isSlideOverDraftDirty,
|
||||||
} from "@/domain/work-orders/utils/slide-over-draft";
|
} from "@/domain/work-orders/utils/slide-over-draft";
|
||||||
|
import {
|
||||||
|
isWorkOrderCoreLocked,
|
||||||
|
isWorkOrderFullyLocked,
|
||||||
|
} from "@/domain/work-orders/utils/work-order-lock";
|
||||||
import type { SlideOverSaveOptions } from "./work-order-slide-over";
|
import type { SlideOverSaveOptions } from "./work-order-slide-over";
|
||||||
import type { SlideOverTab } from "./use-work-order-slide-over";
|
import type { SlideOverTab } from "./use-work-order-slide-over";
|
||||||
|
|
||||||
|
function isSlideOverInfoLocked(status: string | null | undefined): boolean {
|
||||||
|
return isWorkOrderFullyLocked(status) || isWorkOrderCoreLocked(status);
|
||||||
|
}
|
||||||
|
|
||||||
type UseSlideOverEditStateArgs = {
|
type UseSlideOverEditStateArgs = {
|
||||||
row: WorkOrderTableRow | null;
|
row: WorkOrderTableRow | null;
|
||||||
editMode: boolean;
|
editMode: boolean;
|
||||||
|
|
@ -49,7 +57,7 @@ export function useSlideOverEditState({
|
||||||
}
|
}
|
||||||
setDraft(row);
|
setDraft(row);
|
||||||
setBaseline(row);
|
setBaseline(row);
|
||||||
setEditing(editMode && (row.status as string) !== "Canceled");
|
setEditing(editMode && !isSlideOverInfoLocked(row.status));
|
||||||
setTab("info");
|
setTab("info");
|
||||||
setShowUnsaved(false);
|
setShowUnsaved(false);
|
||||||
setUnsavedIntent(null);
|
setUnsavedIntent(null);
|
||||||
|
|
@ -57,6 +65,21 @@ export function useSlideOverEditState({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
|
||||||
}, [row?.id, editMode]);
|
}, [row?.id, editMode]);
|
||||||
|
|
||||||
|
const lockStatus = infoSource?.status ?? row?.status;
|
||||||
|
const isInfoLocked = isSlideOverInfoLocked(lockStatus);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInfoLocked) return;
|
||||||
|
setEditing(false);
|
||||||
|
setShowUnsaved(false);
|
||||||
|
setUnsavedIntent(null);
|
||||||
|
const source = infoSource ?? row;
|
||||||
|
if (source) {
|
||||||
|
setDraft(source);
|
||||||
|
setBaseline(source);
|
||||||
|
}
|
||||||
|
}, [isInfoLocked, infoSource, row]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!infoSource) return;
|
if (!infoSource) return;
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
|
|
@ -84,6 +107,7 @@ export function useSlideOverEditState({
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
if (!row || !activeDraft || !dirtyBaseline) return;
|
if (!row || !activeDraft || !dirtyBaseline) return;
|
||||||
|
if (isSlideOverInfoLocked(infoSource?.status ?? row.status)) return;
|
||||||
const patch = buildSlideOverPatch(activeDraft, dirtyBaseline);
|
const patch = buildSlideOverPatch(activeDraft, dirtyBaseline);
|
||||||
if (Object.keys(patch).length === 0) {
|
if (Object.keys(patch).length === 0) {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
|
|
@ -138,7 +162,7 @@ export function useSlideOverEditState({
|
||||||
};
|
};
|
||||||
|
|
||||||
const startEditing = () => {
|
const startEditing = () => {
|
||||||
if (!infoSource) return;
|
if (!infoSource || isSlideOverInfoLocked(infoSource.status)) return;
|
||||||
setDraft(infoSource);
|
setDraft(infoSource);
|
||||||
setBaseline(infoSource);
|
setBaseline(infoSource);
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,18 @@ import {
|
||||||
useUploadCompletionDoc,
|
useUploadCompletionDoc,
|
||||||
useUploadWorkOrderMedia,
|
useUploadWorkOrderMedia,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
|
import {
|
||||||
|
canMutateWorkOrderComments,
|
||||||
|
canMutateWorkOrderExtraDocs,
|
||||||
|
isWorkOrderCompleted,
|
||||||
|
isWorkOrderFullyLocked,
|
||||||
|
} from "@/domain/work-orders/utils/work-order-lock";
|
||||||
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
import { useAuthContext } from "@/providers/auth-context";
|
import { useAuthContext } from "@/providers/auth-context";
|
||||||
import { useSlideOverEditState } from "./use-slide-over-edit-state";
|
import { useSlideOverEditState } from "./use-slide-over-edit-state";
|
||||||
import { useSlideOverMediaState } from "./use-slide-over-media-state";
|
import { useSlideOverMediaState } from "./use-slide-over-media-state";
|
||||||
|
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||||
import type { SlideOverSaveOptions } from "./work-order-slide-over";
|
import type { SlideOverSaveOptions } from "./work-order-slide-over";
|
||||||
|
|
||||||
export type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras";
|
export type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras";
|
||||||
|
|
@ -30,6 +37,75 @@ type UseWorkOrderSlideOverArgs = {
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CompletionUploadMutate = (
|
||||||
|
input: { file: File; workOrderVersion: string },
|
||||||
|
options: { onSuccess: () => Promise<void> | void },
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
function uploadSlideOverCompletionPdf({
|
||||||
|
file,
|
||||||
|
row,
|
||||||
|
infoSource,
|
||||||
|
onSave,
|
||||||
|
mutate,
|
||||||
|
}: {
|
||||||
|
file: File;
|
||||||
|
row: WorkOrderTableRow | null;
|
||||||
|
infoSource: WorkOrderTableRow | null | undefined;
|
||||||
|
onSave: UseWorkOrderSlideOverArgs["onSave"];
|
||||||
|
mutate: CompletionUploadMutate;
|
||||||
|
}) {
|
||||||
|
const version = infoSource?.rowVersion;
|
||||||
|
if (!version || !row || !infoSource) {
|
||||||
|
toast.error("Missing work order version. Refresh and try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mutate(
|
||||||
|
{ file, workOrderVersion: version },
|
||||||
|
{
|
||||||
|
onSuccess: async () => {
|
||||||
|
const patch: WorkOrderTablePatch = { docStatus: "Yes" };
|
||||||
|
if (!infoSource.completedDate) {
|
||||||
|
patch.completedDate = todayIso();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
onSave(row.id, patch, {
|
||||||
|
onSuccess: () => resolve(),
|
||||||
|
onError: (error) => reject(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast.error("Uploaded PDF but failed to update document status. Refresh and retry.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSlideOverSource(
|
||||||
|
detail: WorkOrderBoardDetail | undefined,
|
||||||
|
row: WorkOrderTableRow | null,
|
||||||
|
) {
|
||||||
|
const detailForRow =
|
||||||
|
detail && row && String(detail.info.id) === String(row.id) ? detail : undefined;
|
||||||
|
return { detailForRow, infoSource: detailForRow?.info ?? row };
|
||||||
|
}
|
||||||
|
|
||||||
|
function slideOverReadFlags(status: string | null | undefined) {
|
||||||
|
return {
|
||||||
|
isCancelled: isWorkOrderFullyLocked(status),
|
||||||
|
isCompleted: isWorkOrderCompleted(status),
|
||||||
|
isDocumentsReadOnly: isWorkOrderDocumentsReadOnly(status),
|
||||||
|
isCommentsReadOnly: !canMutateWorkOrderComments(status),
|
||||||
|
isExtrasReadOnly: !canMutateWorkOrderExtraDocs(status),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function slideOverErrorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : "Failed to load work order details.";
|
||||||
|
}
|
||||||
|
|
||||||
export function useWorkOrderSlideOver({
|
export function useWorkOrderSlideOver({
|
||||||
row,
|
row,
|
||||||
editMode,
|
editMode,
|
||||||
|
|
@ -54,13 +130,8 @@ export function useWorkOrderSlideOver({
|
||||||
const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? "");
|
const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? "");
|
||||||
|
|
||||||
const [tab, setTab] = useState<SlideOverTab>("info");
|
const [tab, setTab] = useState<SlideOverTab>("info");
|
||||||
|
const { detailForRow, infoSource } = resolveSlideOverSource(detail, row);
|
||||||
// Prefer row until detail for this id arrives — avoids cross-WO bleed while loading.
|
const readFlags = slideOverReadFlags(infoSource?.status);
|
||||||
const detailForRow =
|
|
||||||
detail && row && String(detail.info.id) === String(row.id) ? detail : undefined;
|
|
||||||
const infoSource = detailForRow?.info ?? row;
|
|
||||||
const isCancelled = (infoSource?.status as string) === "Canceled";
|
|
||||||
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(infoSource?.status);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
completionMedia,
|
completionMedia,
|
||||||
|
|
@ -92,38 +163,17 @@ export function useWorkOrderSlideOver({
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadCompletionPdf = (file: File) => {
|
const uploadCompletionPdf = (file: File) => {
|
||||||
const version = infoSource?.rowVersion;
|
uploadSlideOverCompletionPdf({
|
||||||
if (!version || !row || !infoSource) {
|
file,
|
||||||
toast.error("Missing work order version. Refresh and try again.");
|
row,
|
||||||
return;
|
infoSource,
|
||||||
}
|
onSave,
|
||||||
uploadCompletionMutation.mutate(
|
mutate: uploadCompletionMutation.mutate,
|
||||||
{ file, workOrderVersion: version },
|
});
|
||||||
{
|
|
||||||
onSuccess: async () => {
|
|
||||||
// Mirror CompDoc finalize: ensure completedDate then mark docStatus Yes.
|
|
||||||
const patch: WorkOrderTablePatch = { docStatus: "Yes" };
|
|
||||||
if (!infoSource.completedDate) {
|
|
||||||
patch.completedDate = todayIso();
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
onSave(row.id, patch, {
|
|
||||||
onSuccess: () => resolve(),
|
|
||||||
onError: (error) => reject(error),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
toast.error("Uploaded PDF but failed to update document status. Refresh and retry.");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const showDetailSkeleton = isLoading && !detailForRow;
|
const showDetailSkeleton = isLoading && !detailForRow;
|
||||||
const errorMessage =
|
const errorMessage = slideOverErrorMessage(error);
|
||||||
error instanceof Error ? error.message : "Failed to load work order details.";
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
|
|
@ -138,8 +188,7 @@ export function useWorkOrderSlideOver({
|
||||||
setTab,
|
setTab,
|
||||||
detailForRow,
|
detailForRow,
|
||||||
infoSource,
|
infoSource,
|
||||||
isCancelled,
|
...readFlags,
|
||||||
isDocumentsReadOnly,
|
|
||||||
completionMedia,
|
completionMedia,
|
||||||
extrasUploads,
|
extrasUploads,
|
||||||
mediaPending,
|
mediaPending,
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ type WorkOrderSlideOverTabsProps = {
|
||||||
allOrders: WorkOrderTableRow[];
|
allOrders: WorkOrderTableRow[];
|
||||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
onOpenCompletionDoc: () => void;
|
onOpenCompletionDoc: () => void;
|
||||||
|
onRequestComplete?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WorkOrderSlideOverTabs({
|
export function WorkOrderSlideOverTabs({
|
||||||
|
|
@ -39,6 +40,7 @@ export function WorkOrderSlideOverTabs({
|
||||||
allOrders,
|
allOrders,
|
||||||
onDuplicateFound,
|
onDuplicateFound,
|
||||||
onOpenCompletionDoc,
|
onOpenCompletionDoc,
|
||||||
|
onRequestComplete,
|
||||||
}: WorkOrderSlideOverTabsProps) {
|
}: WorkOrderSlideOverTabsProps) {
|
||||||
const hasError = Boolean(state.error) && !state.detailForRow;
|
const hasError = Boolean(state.error) && !state.detailForRow;
|
||||||
const showContent = Boolean(state.detailForRow || !state.isLoading);
|
const showContent = Boolean(state.detailForRow || !state.isLoading);
|
||||||
|
|
@ -52,6 +54,7 @@ export function WorkOrderSlideOverTabs({
|
||||||
draft={activeDraft}
|
draft={activeDraft}
|
||||||
editing={state.editing && !saving}
|
editing={state.editing && !saving}
|
||||||
isCancelled={state.isCancelled}
|
isCancelled={state.isCancelled}
|
||||||
|
isCompleted={state.isCompleted}
|
||||||
users={users}
|
users={users}
|
||||||
sites={sites}
|
sites={sites}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
|
|
@ -59,6 +62,7 @@ export function WorkOrderSlideOverTabs({
|
||||||
allOrders={allOrders}
|
allOrders={allOrders}
|
||||||
onDraftChange={state.handleDraftChange}
|
onDraftChange={state.handleDraftChange}
|
||||||
onDuplicateFound={onDuplicateFound}
|
onDuplicateFound={onDuplicateFound}
|
||||||
|
onRequestComplete={onRequestComplete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -74,7 +78,7 @@ export function WorkOrderSlideOverTabs({
|
||||||
{state.detailForRow != null && (
|
{state.detailForRow != null && (
|
||||||
<SlideOverCommentsTab
|
<SlideOverCommentsTab
|
||||||
comments={state.detailForRow.comments}
|
comments={state.detailForRow.comments}
|
||||||
isReadOnly={state.isDocumentsReadOnly}
|
isReadOnly={state.isCommentsReadOnly}
|
||||||
isPending={state.commentMutation.isPending}
|
isPending={state.commentMutation.isPending}
|
||||||
currentUserId={state.user?.id}
|
currentUserId={state.user?.id}
|
||||||
isUpdatePending={state.updateCommentMutation.isPending}
|
isUpdatePending={state.updateCommentMutation.isPending}
|
||||||
|
|
@ -142,7 +146,7 @@ export function WorkOrderSlideOverTabs({
|
||||||
>
|
>
|
||||||
<SlideOverExtrasTab
|
<SlideOverExtrasTab
|
||||||
uploads={state.extrasUploads}
|
uploads={state.extrasUploads}
|
||||||
isReadOnly={state.isDocumentsReadOnly}
|
isReadOnly={state.isExtrasReadOnly}
|
||||||
pending={state.mediaPending}
|
pending={state.mediaPending}
|
||||||
onUpload={(files) => {
|
onUpload={(files) => {
|
||||||
void state.uploadExtraFiles(files);
|
void state.uploadExtraFiles(files);
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ type WorkOrderSlideOverProps = {
|
||||||
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
|
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
|
||||||
onOpenCompletionDoc: (row: WorkOrderTableRow) => void;
|
onOpenCompletionDoc: (row: WorkOrderTableRow) => void;
|
||||||
onCancelWO: (row: WorkOrderTableRow) => void;
|
onCancelWO: (row: WorkOrderTableRow) => void;
|
||||||
|
onRequestComplete?: (row: WorkOrderTableRow) => void;
|
||||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
|
|
@ -47,6 +48,7 @@ export function WorkOrderSlideOver({
|
||||||
onSave,
|
onSave,
|
||||||
onOpenCompletionDoc,
|
onOpenCompletionDoc,
|
||||||
onCancelWO,
|
onCancelWO,
|
||||||
|
onRequestComplete,
|
||||||
onDuplicateFound,
|
onDuplicateFound,
|
||||||
onClose,
|
onClose,
|
||||||
saving,
|
saving,
|
||||||
|
|
@ -85,10 +87,12 @@ export function WorkOrderSlideOver({
|
||||||
allOrders={allOrders}
|
allOrders={allOrders}
|
||||||
onDuplicateFound={onDuplicateFound}
|
onDuplicateFound={onDuplicateFound}
|
||||||
onOpenCompletionDoc={() => onOpenCompletionDoc(infoSource)}
|
onOpenCompletionDoc={() => onOpenCompletionDoc(infoSource)}
|
||||||
|
onRequestComplete={() => onRequestComplete?.(infoSource)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SlideOverFooter
|
<SlideOverFooter
|
||||||
isCancelled={state.isCancelled}
|
isCancelled={state.isCancelled}
|
||||||
|
isCompleted={state.isCompleted}
|
||||||
editing={state.editing}
|
editing={state.editing}
|
||||||
isDirty={state.isDirty}
|
isDirty={state.isDirty}
|
||||||
showUnsaved={state.showUnsaved}
|
showUnsaved={state.showUnsaved}
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,11 @@ import {
|
||||||
import { AdvDocPill, AdvStatusPill } from "./adv-filter-pill";
|
import { AdvDocPill, AdvStatusPill } from "./adv-filter-pill";
|
||||||
import { AdvDateRangeSection } from "./adv-date-range-section";
|
import { AdvDateRangeSection } from "./adv-date-range-section";
|
||||||
import { AdvMultiSelect } from "./adv-multi-select";
|
import { AdvMultiSelect } from "./adv-multi-select";
|
||||||
import { AdvParityFilterSections } from "./adv-parity-filter-sections";
|
import { AdvFlagInternalUpliftSections, AdvIndicatorsSection } from "./adv-parity-filter-sections";
|
||||||
import { AdvSection } from "./adv-section";
|
import { AdvSection } from "./adv-section";
|
||||||
import { AdvSeverityList } from "./adv-severity-list";
|
import { AdvSeverityList } from "./adv-severity-list";
|
||||||
import { AdvTypeGrid } from "./adv-type-grid";
|
import { AdvTypeGrid } from "./adv-type-grid";
|
||||||
|
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||||
import { SelectAllAction } from "@/components/ui/select-all-action";
|
import { SelectAllAction } from "@/components/ui/select-all-action";
|
||||||
import {
|
import {
|
||||||
ALL_WIZARD_STATUSES,
|
ALL_WIZARD_STATUSES,
|
||||||
|
|
@ -124,20 +125,18 @@ export function AdvancedSearchFilterSections({
|
||||||
</div>
|
</div>
|
||||||
</AdvSection>
|
</AdvSection>
|
||||||
|
|
||||||
<AdvParityFilterSections
|
<AdvIndicatorsSection draft={draft} setDraft={setDraft} />
|
||||||
draft={draft}
|
|
||||||
setDraft={setDraft}
|
|
||||||
toggleIn={toggleIn}
|
|
||||||
setMany={setMany}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AdvSection icon={Flame} label="PM type" count={draft.pmTypes.size}>
|
<AdvSection icon={Flame} label="Service" count={draft.pmTypes.size}>
|
||||||
<AdvMultiSelect
|
<AdvMultiSelect
|
||||||
options={pmTypes.map((pmType) => ({ value: pmType, label: pmType }))}
|
options={pmTypes.map((pmType) => ({ value: pmType, label: pmType }))}
|
||||||
selected={draft.pmTypes}
|
selected={draft.pmTypes}
|
||||||
onToggle={(value) => toggleIn("pmTypes", value)}
|
onToggle={(value) => toggleIn("pmTypes", value)}
|
||||||
onBulkToggle={(values, select) => setMany("pmTypes", values, select)}
|
onBulkToggle={(values, select) => setMany("pmTypes", values, select)}
|
||||||
placeholder="Search pm type…"
|
placeholder="Search service…"
|
||||||
|
renderLeft={(pmType) => (
|
||||||
|
<PmTypeIcon pmType={pmType} size={14} className="shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</AdvSection>
|
</AdvSection>
|
||||||
|
|
||||||
|
|
@ -164,6 +163,13 @@ export function AdvancedSearchFilterSections({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</AdvSection>
|
</AdvSection>
|
||||||
|
|
||||||
|
<AdvFlagInternalUpliftSections
|
||||||
|
draft={draft}
|
||||||
|
setDraft={setDraft}
|
||||||
|
toggleIn={toggleIn}
|
||||||
|
setMany={setMany}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||||
import { Check, DollarSign, Flag, Hash, RefreshCw } from "lucide-react";
|
import { ArrowRight, Check, DollarSign, Flag, Hash, Plus, RefreshCw } from "lucide-react";
|
||||||
import { Checkbox } from "@mui/material";
|
import { Checkbox } from "@mui/material";
|
||||||
import { AdvDocPill, AdvFilterPill } from "./adv-filter-pill";
|
import { AdvDocPill } from "./adv-filter-pill";
|
||||||
import { AdvMultiSelect } from "./adv-multi-select";
|
import { AdvMultiSelect } from "./adv-multi-select";
|
||||||
import { AdvSection } from "./adv-section";
|
import { AdvSection } from "./adv-section";
|
||||||
import { FLAG_COLORS } from "@/domain/work-orders/flag-colors";
|
import { FLAG_COLORS } from "@/domain/work-orders/flag-colors";
|
||||||
|
|
@ -15,9 +15,12 @@ import {
|
||||||
type UpliftFilterStatus,
|
type UpliftFilterStatus,
|
||||||
} from "@/domain/work-orders/types/work-order-uplift-filter";
|
} from "@/domain/work-orders/types/work-order-uplift-filter";
|
||||||
|
|
||||||
type AdvParityFilterSectionsProps = {
|
type DraftProps = {
|
||||||
draft: WorkOrderAdvancedFilters;
|
draft: WorkOrderAdvancedFilters;
|
||||||
setDraft: Dispatch<SetStateAction<WorkOrderAdvancedFilters>>;
|
setDraft: Dispatch<SetStateAction<WorkOrderAdvancedFilters>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdvFlagInternalUpliftSectionsProps = DraftProps & {
|
||||||
toggleIn: (key: AdvancedFilterSetKey, value: string) => void;
|
toggleIn: (key: AdvancedFilterSetKey, value: string) => void;
|
||||||
setMany: (key: AdvancedFilterSetKey, values: string[], select: boolean) => void;
|
setMany: (key: AdvancedFilterSetKey, values: string[], select: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
@ -35,12 +38,89 @@ function FlagColorDot({ value }: { value: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdvParityFilterSections({
|
type IndicatorKind = "rescheduled" | "carriedOver" | "addOn";
|
||||||
|
|
||||||
|
const INDICATOR_SELECTED: Record<IndicatorKind, { background: string; color: string }> = {
|
||||||
|
rescheduled: { background: "#f5f3ff", color: "#7c3aed" },
|
||||||
|
carriedOver: { background: "var(--color-bg-muted)", color: "var(--color-text-primary)" },
|
||||||
|
addOn: { background: "var(--type-addon-bg)", color: "var(--type-addon-text)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function IndicatorPill({
|
||||||
|
selected,
|
||||||
|
onClick,
|
||||||
|
kind,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
selected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
kind: IndicatorKind;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const selectedLook = INDICATOR_SELECTED[kind];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
data-indicator={kind}
|
||||||
|
data-selected={selected ? "true" : "false"}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
style={{
|
||||||
|
height: 26,
|
||||||
|
padding: "0 9px",
|
||||||
|
borderRadius: 9999,
|
||||||
|
border: `1px solid ${selected ? "var(--color-primary)" : "var(--color-border)"}`,
|
||||||
|
background: selected ? selectedLook.background : "var(--color-bg-surface)",
|
||||||
|
color: selected ? selectedLook.color : "var(--color-text-secondary)",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdvIndicatorsSection({ draft, setDraft }: DraftProps) {
|
||||||
|
return (
|
||||||
|
<AdvSection icon={RefreshCw} label="Indicators" count={indicatorCount(draft)}>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<IndicatorPill
|
||||||
|
kind="rescheduled"
|
||||||
|
selected={draft.rescheduled}
|
||||||
|
onClick={() => setDraft((prev) => ({ ...prev, rescheduled: !prev.rescheduled }))}
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} className="shrink-0" style={{ color: "#7c3aed" }} />
|
||||||
|
Rescheduled ≥ 2
|
||||||
|
</IndicatorPill>
|
||||||
|
<IndicatorPill
|
||||||
|
kind="carriedOver"
|
||||||
|
selected={draft.carriedOver}
|
||||||
|
onClick={() => setDraft((prev) => ({ ...prev, carriedOver: !prev.carriedOver }))}
|
||||||
|
>
|
||||||
|
<ArrowRight size={12} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
|
||||||
|
Carried Over ≥ 2
|
||||||
|
</IndicatorPill>
|
||||||
|
<IndicatorPill
|
||||||
|
kind="addOn"
|
||||||
|
selected={draft.addOn}
|
||||||
|
onClick={() => setDraft((prev) => ({ ...prev, addOn: !prev.addOn }))}
|
||||||
|
>
|
||||||
|
<Plus size={12} className="shrink-0" style={{ color: "var(--type-addon-text)" }} />
|
||||||
|
Add-On
|
||||||
|
</IndicatorPill>
|
||||||
|
</div>
|
||||||
|
</AdvSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdvFlagInternalUpliftSections({
|
||||||
draft,
|
draft,
|
||||||
setDraft,
|
setDraft,
|
||||||
toggleIn,
|
toggleIn,
|
||||||
setMany,
|
setMany,
|
||||||
}: AdvParityFilterSectionsProps) {
|
}: AdvFlagInternalUpliftSectionsProps) {
|
||||||
const toggleUpliftStatus = (status: UpliftFilterStatus) => {
|
const toggleUpliftStatus = (status: UpliftFilterStatus) => {
|
||||||
setDraft((prev) => {
|
setDraft((prev) => {
|
||||||
const next = new Set(prev.upliftStatuses);
|
const next = new Set(prev.upliftStatuses);
|
||||||
|
|
@ -55,29 +135,6 @@ export function AdvParityFilterSections({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdvSection icon={RefreshCw} label="Indicators" count={indicatorCount(draft)}>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
<AdvFilterPill
|
|
||||||
label="Rescheduled ≥ 2"
|
|
||||||
selected={draft.rescheduled}
|
|
||||||
onClick={() => setDraft((prev) => ({ ...prev, rescheduled: !prev.rescheduled }))}
|
|
||||||
className="inline-flex items-center gap-1.5"
|
|
||||||
/>
|
|
||||||
<AdvFilterPill
|
|
||||||
label="Carried Over ≥ 2"
|
|
||||||
selected={draft.carriedOver}
|
|
||||||
onClick={() => setDraft((prev) => ({ ...prev, carriedOver: !prev.carriedOver }))}
|
|
||||||
className="inline-flex items-center gap-1.5"
|
|
||||||
/>
|
|
||||||
<AdvFilterPill
|
|
||||||
label="Add-On"
|
|
||||||
selected={draft.addOn}
|
|
||||||
onClick={() => setDraft((prev) => ({ ...prev, addOn: !prev.addOn }))}
|
|
||||||
className="inline-flex items-center gap-1.5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</AdvSection>
|
|
||||||
|
|
||||||
<AdvSection icon={Flag} label="Flag" count={draft.flagColors.size}>
|
<AdvSection icon={Flag} label="Flag" count={draft.flagColors.size}>
|
||||||
<AdvMultiSelect
|
<AdvMultiSelect
|
||||||
options={FLAG_COLORS.map((color) => ({ value: color.value, label: color.name }))}
|
options={FLAG_COLORS.map((color) => ({ value: color.value, label: color.name }))}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ type EditableWONumberCellProps = {
|
||||||
allOrders: WorkOrderTableRow[];
|
allOrders: WorkOrderTableRow[];
|
||||||
onSave: (v: string) => void;
|
onSave: (v: string) => void;
|
||||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
|
locked?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function CounterPill({ label, count }: { label: string; count: number }) {
|
function CounterPill({ label, count }: { label: string; count: number }) {
|
||||||
|
|
@ -74,6 +75,7 @@ export function EditableWONumberCell({
|
||||||
allOrders,
|
allOrders,
|
||||||
onSave,
|
onSave,
|
||||||
onDuplicateFound,
|
onDuplicateFound,
|
||||||
|
locked = false,
|
||||||
}: EditableWONumberCellProps) {
|
}: EditableWONumberCellProps) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(value);
|
const [draft, setDraft] = useState(value);
|
||||||
|
|
@ -131,6 +133,29 @@ export function EditableWONumberCell({
|
||||||
|
|
||||||
const invalid = editing && draft.length > 0 && draft.length !== 11;
|
const invalid = editing && draft.length > 0 && draft.length !== 11;
|
||||||
|
|
||||||
|
if (locked) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
title="WO number cannot be changed after completion"
|
||||||
|
className="inline-block cursor-default font-mono"
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: value ? "var(--foreground)" : "var(--color-text-muted)",
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "2px 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value ? <HighlightText text={value} q={q} /> : "-"}
|
||||||
|
</span>
|
||||||
|
<AddOnPill when={isAddOn} />
|
||||||
|
<CounterPill label="Reschedules" count={rescheduleCount} />
|
||||||
|
<CounterPill label="Carried over" count={carriedOver} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{editing ? (
|
{editing ? (
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ type PMTypeCellProps = {
|
||||||
extraServices?: string[];
|
extraServices?: string[];
|
||||||
woNumber?: string;
|
woNumber?: string;
|
||||||
onSaveDetails?: (details: ServiceNotesDetails) => void;
|
onSaveDetails?: (details: ServiceNotesDetails) => void;
|
||||||
|
viewOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PMTypeCell({
|
export function PMTypeCell({
|
||||||
|
|
@ -33,6 +34,7 @@ export function PMTypeCell({
|
||||||
extraServices = [],
|
extraServices = [],
|
||||||
woNumber,
|
woNumber,
|
||||||
onSaveDetails,
|
onSaveDetails,
|
||||||
|
viewOnly = false,
|
||||||
}: PMTypeCellProps) {
|
}: PMTypeCellProps) {
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
@ -52,7 +54,7 @@ export function PMTypeCell({
|
||||||
};
|
};
|
||||||
|
|
||||||
const modal =
|
const modal =
|
||||||
onSaveDetails != null ? (
|
onSaveDetails != null || viewOnly ? (
|
||||||
<ServiceNotesDialog
|
<ServiceNotesDialog
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
onOpenChange={setModalOpen}
|
onOpenChange={setModalOpen}
|
||||||
|
|
@ -61,10 +63,44 @@ export function PMTypeCell({
|
||||||
serviceNotes={serviceNotes ?? ""}
|
serviceNotes={serviceNotes ?? ""}
|
||||||
woNumber={woNumber}
|
woNumber={woNumber}
|
||||||
pmTypes={pmTypes}
|
pmTypes={pmTypes}
|
||||||
onSave={onSaveDetails}
|
viewOnly={viewOnly}
|
||||||
|
onSave={onSaveDetails ?? (() => undefined)}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
if (viewOnly) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
<PmTypeIcon
|
||||||
|
pmType={value}
|
||||||
|
size={12}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
/>
|
||||||
|
{value ? (
|
||||||
|
<span
|
||||||
|
className="min-w-0 truncate"
|
||||||
|
style={{ fontSize: 13, color: "var(--color-text-primary)" }}
|
||||||
|
>
|
||||||
|
<HighlightText text={value} q={q} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>—</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{modal}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (hasDetails && onSaveDetails) {
|
if (hasDetails && onSaveDetails) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ type ServiceNotesDialogProps = {
|
||||||
serviceNotes: string;
|
serviceNotes: string;
|
||||||
woNumber?: string;
|
woNumber?: string;
|
||||||
pmTypes: string[];
|
pmTypes: string[];
|
||||||
|
viewOnly?: boolean;
|
||||||
onSave: (details: ServiceNotesDetails) => void;
|
onSave: (details: ServiceNotesDetails) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -30,6 +31,7 @@ export function ServiceNotesDialog({
|
||||||
serviceNotes,
|
serviceNotes,
|
||||||
woNumber,
|
woNumber,
|
||||||
pmTypes,
|
pmTypes,
|
||||||
|
viewOnly = false,
|
||||||
onSave,
|
onSave,
|
||||||
}: ServiceNotesDialogProps) {
|
}: ServiceNotesDialogProps) {
|
||||||
const [sel, setSel] = useState<string[]>([]);
|
const [sel, setSel] = useState<string[]>([]);
|
||||||
|
|
@ -76,38 +78,52 @@ export function ServiceNotesDialog({
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{ paddingTop: 8 }}
|
style={{ paddingTop: 8 }}
|
||||||
>
|
>
|
||||||
<WizardServiceNotesFields
|
<fieldset disabled={viewOnly} className="min-w-0 space-y-4 border-0 p-0">
|
||||||
services={services}
|
<WizardServiceNotesFields
|
||||||
sel={sel}
|
services={services}
|
||||||
onToggle={toggle}
|
sel={sel}
|
||||||
note={note}
|
onToggle={toggle}
|
||||||
setNote={setNote}
|
note={note}
|
||||||
search={search}
|
setNote={setNote}
|
||||||
setSearch={setSearch}
|
search={search}
|
||||||
/>
|
setSearch={setSearch}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
<div className="mt-2 flex items-center justify-end gap-2">
|
<div className="mt-2 flex items-center justify-end gap-2">
|
||||||
<Button
|
{viewOnly ? (
|
||||||
type="button"
|
<Button
|
||||||
variant="ghost"
|
type="button"
|
||||||
className="min-h-0 px-2 py-1 text-[12px]"
|
className="min-h-0 px-3 py-1 text-[12px]"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
) : (
|
||||||
type="button"
|
<>
|
||||||
className="min-h-0 px-3 py-1 text-[12px]"
|
<Button
|
||||||
onClick={() => {
|
type="button"
|
||||||
onSave({
|
variant="ghost"
|
||||||
pm: sel[0] ?? "",
|
className="min-h-0 px-2 py-1 text-[12px]"
|
||||||
extraServices: sel.slice(1),
|
onClick={() => onOpenChange(false)}
|
||||||
serviceNotes: note,
|
>
|
||||||
});
|
Cancel
|
||||||
onOpenChange(false);
|
</Button>
|
||||||
}}
|
<Button
|
||||||
>
|
type="button"
|
||||||
Save
|
className="min-h-0 px-3 py-1 text-[12px]"
|
||||||
</Button>
|
onClick={() => {
|
||||||
|
onSave({
|
||||||
|
pm: sel[0] ?? "",
|
||||||
|
extraServices: sel.slice(1),
|
||||||
|
serviceNotes: note,
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ type SiteCellProps = {
|
||||||
q?: string;
|
q?: string;
|
||||||
/** Inline create: editable POC + Site/POC required to confirm the modal. */
|
/** Inline create: editable POC + Site/POC required to confirm the modal. */
|
||||||
createMode?: boolean;
|
createMode?: boolean;
|
||||||
|
viewOnly?: boolean;
|
||||||
onSave: (patch: SitePatch) => void;
|
onSave: (patch: SitePatch) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -37,6 +38,7 @@ export function SiteCell({
|
||||||
sites,
|
sites,
|
||||||
q,
|
q,
|
||||||
createMode = false,
|
createMode = false,
|
||||||
|
viewOnly = false,
|
||||||
onSave,
|
onSave,
|
||||||
}: SiteCellProps) {
|
}: SiteCellProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
@ -99,6 +101,7 @@ export function SiteCell({
|
||||||
additionalContacts={additionalContacts}
|
additionalContacts={additionalContacts}
|
||||||
sites={sites}
|
sites={sites}
|
||||||
createMode={createMode}
|
createMode={createMode}
|
||||||
|
viewOnly={viewOnly}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,20 @@ import { Button } from "@/components/ui/button";
|
||||||
type SiteDialogFooterProps = {
|
type SiteDialogFooterProps = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
|
viewOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SiteDialogFooter({ onClose, onConfirm }: SiteDialogFooterProps) {
|
export function SiteDialogFooter({ onClose, onConfirm, viewOnly = false }: SiteDialogFooterProps) {
|
||||||
|
if (viewOnly) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
|
<Button className="min-h-0 px-3 py-1 text-[12px]" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 flex items-center justify-end gap-2">
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
<Button variant="ghost" className="min-h-0 px-2 py-1 text-[12px]" onClick={onClose}>
|
<Button variant="ghost" className="min-h-0 px-2 py-1 text-[12px]" onClick={onClose}>
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ type SiteDialogProps = {
|
||||||
additionalContacts?: WorkOrderAdditionalContact[];
|
additionalContacts?: WorkOrderAdditionalContact[];
|
||||||
sites: LocationOption[];
|
sites: LocationOption[];
|
||||||
createMode?: boolean;
|
createMode?: boolean;
|
||||||
|
viewOnly?: boolean;
|
||||||
onSave: (patch: SitePatch) => void;
|
onSave: (patch: SitePatch) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -40,6 +41,7 @@ export function SiteDialog({
|
||||||
additionalContacts = [],
|
additionalContacts = [],
|
||||||
sites,
|
sites,
|
||||||
createMode = false,
|
createMode = false,
|
||||||
|
viewOnly = false,
|
||||||
onSave,
|
onSave,
|
||||||
}: SiteDialogProps) {
|
}: SiteDialogProps) {
|
||||||
const s = useSiteDialogState({
|
const s = useSiteDialogState({
|
||||||
|
|
@ -78,7 +80,7 @@ export function SiteDialog({
|
||||||
Select a site and edit its point of contact
|
Select a site and edit its point of contact
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<div className="mt-3 space-y-3">
|
<fieldset disabled={viewOnly} className="mt-3 space-y-3 border-0 p-0">
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>Site</FieldLabel>
|
<FieldLabel>Site</FieldLabel>
|
||||||
<WizardFieldSelect
|
<WizardFieldSelect
|
||||||
|
|
@ -129,9 +131,9 @@ export function SiteDialog({
|
||||||
onPocNotesChange={s.setNotes}
|
onPocNotesChange={s.setNotes}
|
||||||
onAdditionalContactsChange={s.setExtraContacts}
|
onAdditionalContactsChange={s.setExtraContacts}
|
||||||
/>
|
/>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<SiteDialogFooter onClose={s.close} onConfirm={s.attemptSave} />
|
<SiteDialogFooter viewOnly={viewOnly} onClose={s.close} onConfirm={s.attemptSave} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,33 @@ import {
|
||||||
import { HoverChevron } from "./hover-chevron";
|
import { HoverChevron } from "./hover-chevron";
|
||||||
import { WoTableStatusLabel } from "./wo-table-status-label";
|
import { WoTableStatusLabel } from "./wo-table-status-label";
|
||||||
|
|
||||||
|
const COMPLETED_STATUS_LOCKED_LABEL = "Status is locked because this work order is completed";
|
||||||
|
|
||||||
type StatusCellProps = {
|
type StatusCellProps = {
|
||||||
wo: WorkOrderTableRow;
|
wo: WorkOrderTableRow;
|
||||||
onChangeStatus: (status: WizardWOStatus) => void;
|
onChangeStatus: (status: WizardWOStatus) => void;
|
||||||
onOpenSlideOver: () => void;
|
onOpenSlideOver: () => void;
|
||||||
onPastDueAttempt?: () => void;
|
onPastDueAttempt?: () => void;
|
||||||
|
onRequestComplete?: () => void;
|
||||||
|
readOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function ReadOnlyStatusLabel({ status }: { status: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="flex w-full items-center gap-1"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-label={COMPLETED_STATUS_LOCKED_LABEL}
|
||||||
|
>
|
||||||
|
{status ? (
|
||||||
|
<WoTableStatusLabel status={status} />
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function rowClosabilityGaps(wo: WorkOrderTableRow) {
|
function rowClosabilityGaps(wo: WorkOrderTableRow) {
|
||||||
return getClosabilityGaps(tableRowToClosabilityInput(wo));
|
return getClosabilityGaps(tableRowToClosabilityInput(wo));
|
||||||
}
|
}
|
||||||
|
|
@ -102,10 +122,16 @@ export function StatusCell({
|
||||||
onChangeStatus,
|
onChangeStatus,
|
||||||
onOpenSlideOver,
|
onOpenSlideOver,
|
||||||
onPastDueAttempt,
|
onPastDueAttempt,
|
||||||
|
onRequestComplete,
|
||||||
|
readOnly = false,
|
||||||
}: StatusCellProps) {
|
}: StatusCellProps) {
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
const closabilityGaps = rowClosabilityGaps(wo);
|
const closabilityGaps = rowClosabilityGaps(wo);
|
||||||
|
|
||||||
|
if (readOnly) {
|
||||||
|
return <ReadOnlyStatusLabel status={wo.status} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (wo.isPastDue) {
|
if (wo.isPastDue) {
|
||||||
return (
|
return (
|
||||||
<PastDueStatusCell onOpenSlideOver={onOpenSlideOver} onPastDueAttempt={onPastDueAttempt} />
|
<PastDueStatusCell onOpenSlideOver={onOpenSlideOver} onPastDueAttempt={onPastDueAttempt} />
|
||||||
|
|
@ -154,6 +180,11 @@ export function StatusCell({
|
||||||
if (isCompletedBlocked) {
|
if (isCompletedBlocked) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (status === "Completed" && wo.status !== "Completed") {
|
||||||
|
onRequestComplete?.();
|
||||||
|
setAnchorEl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
onChangeStatus(status);
|
onChangeStatus(status);
|
||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ type VendorCellProps = {
|
||||||
techPhone?: string;
|
techPhone?: string;
|
||||||
vendors: VendorDropdownItem[];
|
vendors: VendorDropdownItem[];
|
||||||
q?: string;
|
q?: string;
|
||||||
|
viewOnly?: boolean;
|
||||||
onSave: (patch: VendorPatch) => void;
|
onSave: (patch: VendorPatch) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -21,6 +22,7 @@ export function VendorCell({
|
||||||
techPhone,
|
techPhone,
|
||||||
vendors,
|
vendors,
|
||||||
q,
|
q,
|
||||||
|
viewOnly = false,
|
||||||
onSave,
|
onSave,
|
||||||
}: VendorCellProps) {
|
}: VendorCellProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
@ -60,6 +62,7 @@ export function VendorCell({
|
||||||
tech={tech}
|
tech={tech}
|
||||||
techPhone={techPhone}
|
techPhone={techPhone}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
|
viewOnly={viewOnly}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,25 @@ type VendorDialogFooterProps = {
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
|
viewOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function VendorDialogFooter({ isPending, onCancel, onSave }: VendorDialogFooterProps) {
|
export function VendorDialogFooter({
|
||||||
|
isPending,
|
||||||
|
onCancel,
|
||||||
|
onSave,
|
||||||
|
viewOnly = false,
|
||||||
|
}: VendorDialogFooterProps) {
|
||||||
|
if (viewOnly) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
|
<Button className="min-h-0 px-3 py-1 text-[12px]" onClick={onCancel}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 flex items-center justify-end gap-2">
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ type VendorDialogProps = {
|
||||||
tech: string;
|
tech: string;
|
||||||
techPhone?: string;
|
techPhone?: string;
|
||||||
vendors: VendorDropdownItem[];
|
vendors: VendorDropdownItem[];
|
||||||
|
viewOnly?: boolean;
|
||||||
onSave: (patch: VendorPatch) => void;
|
onSave: (patch: VendorPatch) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -40,6 +41,7 @@ export function VendorDialog({
|
||||||
tech,
|
tech,
|
||||||
techPhone = "",
|
techPhone = "",
|
||||||
vendors,
|
vendors,
|
||||||
|
viewOnly = false,
|
||||||
onSave,
|
onSave,
|
||||||
}: VendorDialogProps) {
|
}: VendorDialogProps) {
|
||||||
const createVendor = useCreateVendor();
|
const createVendor = useCreateVendor();
|
||||||
|
|
@ -145,24 +147,27 @@ export function VendorDialog({
|
||||||
Search and select the technician
|
Search and select the technician
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<VendorDialogBody
|
<fieldset disabled={viewOnly} className="min-w-0 border-0 p-0">
|
||||||
selectedKey={selectedKey}
|
<VendorDialogBody
|
||||||
technicianOptions={technicianOptions}
|
selectedKey={selectedKey}
|
||||||
anchorCompanyName={anchorCompanyName}
|
technicianOptions={technicianOptions}
|
||||||
companyVendorForAdd={companyVendor ?? undefined}
|
anchorCompanyName={anchorCompanyName}
|
||||||
tech={selected?.name ?? t}
|
companyVendorForAdd={viewOnly ? undefined : (companyVendor ?? undefined)}
|
||||||
phone={phone}
|
tech={selected?.name ?? t}
|
||||||
notes={notes}
|
phone={phone}
|
||||||
resolvedCompany={resolvedCompany}
|
notes={notes}
|
||||||
selectedVendor={selectedVendor}
|
resolvedCompany={resolvedCompany}
|
||||||
saveError={saveError}
|
selectedVendor={selectedVendor}
|
||||||
onPick={handlePick}
|
saveError={saveError}
|
||||||
onPhoneChange={setPhone}
|
onPick={handlePick}
|
||||||
onNotesChange={setNotes}
|
onPhoneChange={setPhone}
|
||||||
onTechnicianCreated={handleTechnicianCreated}
|
onNotesChange={setNotes}
|
||||||
/>
|
onTechnicianCreated={handleTechnicianCreated}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<VendorDialogFooter
|
<VendorDialogFooter
|
||||||
|
viewOnly={viewOnly}
|
||||||
isPending={createVendor.isPending}
|
isPending={createVendor.isPending}
|
||||||
onCancel={() => onOpenChange(false)}
|
onCancel={() => onOpenChange(false)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ type UseWoTableRowHandlersOptions = {
|
||||||
onNavigateToDay: (dateIso: string) => void;
|
onNavigateToDay: (dateIso: string) => void;
|
||||||
onOpenAdvancedFilters?: () => void;
|
onOpenAdvancedFilters?: () => void;
|
||||||
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
||||||
|
onRequestComplete?: (row: WorkOrderTableRow) => void;
|
||||||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||||
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
||||||
onOpenMedia: (row: WorkOrderTableRow) => void;
|
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||||
|
|
@ -29,6 +30,7 @@ export function useWoTableRowHandlers({
|
||||||
onNavigateToDay,
|
onNavigateToDay,
|
||||||
onOpenAdvancedFilters,
|
onOpenAdvancedFilters,
|
||||||
onOpenSlideOver,
|
onOpenSlideOver,
|
||||||
|
onRequestComplete,
|
||||||
onOpenCompDoc,
|
onOpenCompDoc,
|
||||||
onOpenUplifts,
|
onOpenUplifts,
|
||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
|
|
@ -110,6 +112,7 @@ export function useWoTableRowHandlers({
|
||||||
onDuplicateFound?.(existing);
|
onDuplicateFound?.(existing);
|
||||||
},
|
},
|
||||||
onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false),
|
onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false),
|
||||||
|
onRequestComplete,
|
||||||
onPastDueAttempt: (id: string | number) => onScheduleHighlight(id),
|
onPastDueAttempt: (id: string | number) => onScheduleHighlight(id),
|
||||||
onReorder: reorderRows,
|
onReorder: reorderRows,
|
||||||
onDragStart: (id: string | number) => {
|
onDragStart: (id: string | number) => {
|
||||||
|
|
@ -143,6 +146,7 @@ export function useWoTableRowHandlers({
|
||||||
onNavigateToDay,
|
onNavigateToDay,
|
||||||
onOpenAdvancedFilters,
|
onOpenAdvancedFilters,
|
||||||
onOpenSlideOver,
|
onOpenSlideOver,
|
||||||
|
onRequestComplete,
|
||||||
onOpenCompDoc,
|
onOpenCompDoc,
|
||||||
onOpenUplifts,
|
onOpenUplifts,
|
||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ type WoTableInlineRowCellsProps = {
|
||||||
woShowError: boolean;
|
woShowError: boolean;
|
||||||
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
|
onRequestComplete?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function InlineTypeSeverityCell({
|
function InlineTypeSeverityCell({
|
||||||
|
|
@ -130,6 +131,7 @@ export function WoTableInlineRowCells({
|
||||||
woShowError,
|
woShowError,
|
||||||
onPatch,
|
onPatch,
|
||||||
onDuplicateFound,
|
onDuplicateFound,
|
||||||
|
onRequestComplete,
|
||||||
}: WoTableInlineRowCellsProps) {
|
}: WoTableInlineRowCellsProps) {
|
||||||
const draftRow = toInlineDraftRow(draft);
|
const draftRow = toInlineDraftRow(draft);
|
||||||
|
|
||||||
|
|
@ -213,6 +215,7 @@ export function WoTableInlineRowCells({
|
||||||
wo={draftRow as never}
|
wo={draftRow as never}
|
||||||
onChangeStatus={(status) => onPatch({ status })}
|
onChangeStatus={(status) => onPatch({ status })}
|
||||||
onOpenSlideOver={() => {}}
|
onOpenSlideOver={() => {}}
|
||||||
|
onRequestComplete={onRequestComplete}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useRef, useState, type RefObject } from "react";
|
import { useRef, useState, type RefObject } from "react";
|
||||||
import { Flag } from "lucide-react";
|
import { Flag } from "lucide-react";
|
||||||
|
import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
LocationOption,
|
LocationOption,
|
||||||
|
|
@ -73,6 +74,7 @@ export function WoTableInlineRow({
|
||||||
const missing = getInlineCreateMissing(draft);
|
const missing = getInlineCreateMissing(draft);
|
||||||
const canSave = !saving && missing.length === 0;
|
const canSave = !saving && missing.length === 0;
|
||||||
const [showErrors, setShowErrors] = useState(false);
|
const [showErrors, setShowErrors] = useState(false);
|
||||||
|
const [confirmComplete, setConfirmComplete] = useState(false);
|
||||||
const siteCellRef = useRef<HTMLTableCellElement>(null);
|
const siteCellRef = useRef<HTMLTableCellElement>(null);
|
||||||
const woCellRef = useRef<HTMLTableCellElement>(null);
|
const woCellRef = useRef<HTMLTableCellElement>(null);
|
||||||
|
|
||||||
|
|
@ -136,6 +138,7 @@ export function WoTableInlineRow({
|
||||||
woShowError={woShowError}
|
woShowError={woShowError}
|
||||||
onPatch={patch}
|
onPatch={patch}
|
||||||
onDuplicateFound={onDuplicateFound}
|
onDuplicateFound={onDuplicateFound}
|
||||||
|
onRequestComplete={() => setConfirmComplete(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<td style={{ padding: "0 4px" }}>
|
<td style={{ padding: "0 4px" }}>
|
||||||
|
|
@ -146,6 +149,16 @@ export function WoTableInlineRow({
|
||||||
onSave={attemptSave}
|
onSave={attemptSave}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
/>
|
/>
|
||||||
|
<WorkOrderCompleteDialog
|
||||||
|
open={confirmComplete}
|
||||||
|
isPending={false}
|
||||||
|
onDialogClose={() => setConfirmComplete(false)}
|
||||||
|
onKeepOpen={() => setConfirmComplete(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
patch({ status: "Completed" });
|
||||||
|
setConfirmComplete(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ type WoTableRowCellsProps = {
|
||||||
forceScheduleHighlight?: boolean;
|
forceScheduleHighlight?: boolean;
|
||||||
forceDueDateHighlight?: boolean;
|
forceDueDateHighlight?: boolean;
|
||||||
mutationCellStyle?: CSSProperties;
|
mutationCellStyle?: CSSProperties;
|
||||||
|
viewOnly?: boolean;
|
||||||
|
canceledLockStyle?: CSSProperties;
|
||||||
handlers: WoTableRowHandlers;
|
handlers: WoTableRowHandlers;
|
||||||
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
|
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
|
||||||
};
|
};
|
||||||
|
|
@ -33,6 +35,8 @@ export function WoTableRowCells({
|
||||||
forceScheduleHighlight,
|
forceScheduleHighlight,
|
||||||
forceDueDateHighlight,
|
forceDueDateHighlight,
|
||||||
mutationCellStyle,
|
mutationCellStyle,
|
||||||
|
viewOnly = false,
|
||||||
|
canceledLockStyle,
|
||||||
handlers,
|
handlers,
|
||||||
onPatchRow,
|
onPatchRow,
|
||||||
}: WoTableRowCellsProps) {
|
}: WoTableRowCellsProps) {
|
||||||
|
|
@ -40,7 +44,7 @@ export function WoTableRowCells({
|
||||||
<>
|
<>
|
||||||
<td
|
<td
|
||||||
className="group/cell"
|
className="group/cell"
|
||||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
style={{ padding: "0 14px", ...canceledLockStyle }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<SiteCell
|
<SiteCell
|
||||||
|
|
@ -55,6 +59,7 @@ export function WoTableRowCells({
|
||||||
additionalContacts={row.additionalContacts}
|
additionalContacts={row.additionalContacts}
|
||||||
sites={sites}
|
sites={sites}
|
||||||
q={search}
|
q={search}
|
||||||
|
viewOnly={viewOnly}
|
||||||
onSave={(p) =>
|
onSave={(p) =>
|
||||||
onPatchRow({
|
onPatchRow({
|
||||||
site: p.site,
|
site: p.site,
|
||||||
|
|
@ -79,6 +84,7 @@ export function WoTableRowCells({
|
||||||
q={search}
|
q={search}
|
||||||
currentId={row.id}
|
currentId={row.id}
|
||||||
allOrders={allRows}
|
allOrders={allRows}
|
||||||
|
locked={viewOnly}
|
||||||
onSave={(v) => onPatchRow({ woNumber: v })}
|
onSave={(v) => onPatchRow({ woNumber: v })}
|
||||||
onDuplicateFound={handlers.onDuplicateFound}
|
onDuplicateFound={handlers.onDuplicateFound}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ type WoTableRowServiceCellsProps = {
|
||||||
pmTypes: string[];
|
pmTypes: string[];
|
||||||
search?: string;
|
search?: string;
|
||||||
mutationCellStyle?: CSSProperties;
|
mutationCellStyle?: CSSProperties;
|
||||||
|
viewOnly?: boolean;
|
||||||
|
canceledLockStyle?: CSSProperties;
|
||||||
handlers: WoTableRowHandlers;
|
handlers: WoTableRowHandlers;
|
||||||
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
|
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
|
||||||
};
|
};
|
||||||
|
|
@ -29,6 +31,8 @@ export function WoTableRowServiceCells({
|
||||||
pmTypes,
|
pmTypes,
|
||||||
search,
|
search,
|
||||||
mutationCellStyle,
|
mutationCellStyle,
|
||||||
|
viewOnly = false,
|
||||||
|
canceledLockStyle,
|
||||||
handlers,
|
handlers,
|
||||||
onPatchRow,
|
onPatchRow,
|
||||||
}: WoTableRowServiceCellsProps) {
|
}: WoTableRowServiceCellsProps) {
|
||||||
|
|
@ -36,7 +40,7 @@ export function WoTableRowServiceCells({
|
||||||
<>
|
<>
|
||||||
<td
|
<td
|
||||||
className="group/cell"
|
className="group/cell"
|
||||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
style={{ padding: "0 14px", ...canceledLockStyle }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<PMTypeCell
|
<PMTypeCell
|
||||||
|
|
@ -46,6 +50,7 @@ export function WoTableRowServiceCells({
|
||||||
serviceNotes={row.serviceNotes}
|
serviceNotes={row.serviceNotes}
|
||||||
extraServices={row.extraServices}
|
extraServices={row.extraServices}
|
||||||
woNumber={row.woNumber}
|
woNumber={row.woNumber}
|
||||||
|
viewOnly={viewOnly}
|
||||||
onSave={(pm) => onPatchRow({ pm })}
|
onSave={(pm) => onPatchRow({ pm })}
|
||||||
onSaveDetails={(d) =>
|
onSaveDetails={(d) =>
|
||||||
onPatchRow({
|
onPatchRow({
|
||||||
|
|
@ -58,7 +63,7 @@ export function WoTableRowServiceCells({
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className="group/cell"
|
className="group/cell"
|
||||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
style={{ padding: "0 14px", ...canceledLockStyle }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<VendorCell
|
<VendorCell
|
||||||
|
|
@ -68,6 +73,7 @@ export function WoTableRowServiceCells({
|
||||||
techPhone={row.techPhone}
|
techPhone={row.techPhone}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
q={search}
|
q={search}
|
||||||
|
viewOnly={viewOnly}
|
||||||
onSave={(p) => onPatchRow(toVendorTablePatch(p))}
|
onSave={(p) => onPatchRow(toVendorTablePatch(p))}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -85,9 +91,11 @@ export function WoTableRowServiceCells({
|
||||||
>
|
>
|
||||||
<StatusCell
|
<StatusCell
|
||||||
wo={row}
|
wo={row}
|
||||||
|
readOnly={viewOnly}
|
||||||
onChangeStatus={(status) => onPatchRow({ status })}
|
onChangeStatus={(status) => onPatchRow({ status })}
|
||||||
onOpenSlideOver={() => handlers.onOpenSlideOver(row)}
|
onOpenSlideOver={() => handlers.onOpenSlideOver(row)}
|
||||||
onPastDueAttempt={() => handlers.onPastDueAttempt?.(row.id)}
|
onPastDueAttempt={() => handlers.onPastDueAttempt?.(row.id)}
|
||||||
|
onRequestComplete={() => handlers.onRequestComplete?.(row)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
|
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ import type {
|
||||||
} from "@/domain/work-orders/types/work-order";
|
} from "@/domain/work-orders/types/work-order";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
import type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
||||||
|
import {
|
||||||
|
canApplyWorkOrderTablePatch,
|
||||||
|
canMutateWorkOrderFlag,
|
||||||
|
isWorkOrderCoreLocked,
|
||||||
|
isWorkOrderFullyLocked,
|
||||||
|
} from "@/domain/work-orders/utils/work-order-lock";
|
||||||
import { FlagCell } from "./cells/flag-cell";
|
import { FlagCell } from "./cells/flag-cell";
|
||||||
import { WoTableRowActions } from "./wo-table-row-actions";
|
import { WoTableRowActions } from "./wo-table-row-actions";
|
||||||
import { WoTableRowCells } from "./wo-table-row-cells";
|
import { WoTableRowCells } from "./wo-table-row-cells";
|
||||||
|
|
@ -28,6 +34,7 @@ export type WoTableRowHandlers = {
|
||||||
onScheduleWeek: (id: string | number, week: string) => void;
|
onScheduleWeek: (id: string | number, week: string) => void;
|
||||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
onOpenSlideOver: (row: WorkOrderTableRow) => void;
|
onOpenSlideOver: (row: WorkOrderTableRow) => void;
|
||||||
|
onRequestComplete?: (row: WorkOrderTableRow) => void;
|
||||||
onPastDueAttempt?: (id: string | number) => void;
|
onPastDueAttempt?: (id: string | number) => void;
|
||||||
onReorder: (dragId: string | number, overId: string | number) => void;
|
onReorder: (dragId: string | number, overId: string | number) => void;
|
||||||
onDragStart: (id: string | number) => void;
|
onDragStart: (id: string | number) => void;
|
||||||
|
|
@ -71,15 +78,33 @@ export function WoTableRow({
|
||||||
}: WoTableRowProps) {
|
}: WoTableRowProps) {
|
||||||
const isDragging = dragViz.dragId === row.id;
|
const isDragging = dragViz.dragId === row.id;
|
||||||
const isDropTarget = dragViz.overId === row.id && dragViz.dragId !== row.id;
|
const isDropTarget = dragViz.overId === row.id && dragViz.dragId !== row.id;
|
||||||
const isCanceled = (row.status as string) === "Canceled";
|
const isCanceled = isWorkOrderFullyLocked(row.status);
|
||||||
|
const isCoreLocked = isWorkOrderCoreLocked(row.status);
|
||||||
|
const closedForEdit = isCanceled || isCoreLocked;
|
||||||
|
|
||||||
|
const guardedHandlers: WoTableRowHandlers = {
|
||||||
|
...handlers,
|
||||||
|
onPatch: (id, patch) => {
|
||||||
|
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
|
||||||
|
handlers.onPatch(id, patch);
|
||||||
|
},
|
||||||
|
onScheduleDate: (id, date) => {
|
||||||
|
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return;
|
||||||
|
handlers.onScheduleDate(id, date);
|
||||||
|
},
|
||||||
|
onScheduleWeek: (id, week) => {
|
||||||
|
if (!canApplyWorkOrderTablePatch(row.status, { targetWeek: week })) return;
|
||||||
|
handlers.onScheduleWeek(id, week);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const patchRow = (patch: Partial<WorkOrderTableRow>) => {
|
const patchRow = (patch: Partial<WorkOrderTableRow>) => {
|
||||||
if (isCanceled) return;
|
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
|
||||||
handlers.onPatch(row.id, patch);
|
handlers.onPatch(row.id, patch);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Block mutation controls on canceled rows while keeping row/View clickable. */
|
const coreLockStyle = closedForEdit ? MUTATION_CELL_STYLE : undefined;
|
||||||
const mutationCellStyle = isCanceled ? MUTATION_CELL_STYLE : undefined;
|
const canceledLockStyle = isCanceled ? MUTATION_CELL_STYLE : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
|
|
@ -122,11 +147,7 @@ export function WoTableRow({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{ padding: "0 0 0 6px", boxShadow: resolveFirstCellShadow(row) }}
|
||||||
padding: "0 0 0 6px",
|
|
||||||
boxShadow: resolveFirstCellShadow(row),
|
|
||||||
...mutationCellStyle,
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|
@ -139,14 +160,11 @@ export function WoTableRow({
|
||||||
<GripVertical size={14} />
|
<GripVertical size={14} />
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td style={{ padding: "0 2px 0 8px" }} onClick={(e) => e.stopPropagation()}>
|
||||||
style={{ padding: "0 2px 0 8px", ...mutationCellStyle }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<FlagCell
|
<FlagCell
|
||||||
color={row.flagColor}
|
color={row.flagColor}
|
||||||
disabled={isCanceled}
|
disabled={!canMutateWorkOrderFlag(row.status)}
|
||||||
onChange={(c) => handlers.onPatch(row.id, { flagColor: c })}
|
onChange={(c) => guardedHandlers.onPatch(row.id, { flagColor: c })}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<WoTableRowCells
|
<WoTableRowCells
|
||||||
|
|
@ -158,8 +176,10 @@ export function WoTableRow({
|
||||||
search={search}
|
search={search}
|
||||||
forceScheduleHighlight={forceScheduleHighlight}
|
forceScheduleHighlight={forceScheduleHighlight}
|
||||||
forceDueDateHighlight={forceDueDateHighlight}
|
forceDueDateHighlight={forceDueDateHighlight}
|
||||||
mutationCellStyle={mutationCellStyle}
|
mutationCellStyle={coreLockStyle}
|
||||||
handlers={handlers}
|
viewOnly={isCoreLocked}
|
||||||
|
canceledLockStyle={canceledLockStyle}
|
||||||
|
handlers={guardedHandlers}
|
||||||
onPatchRow={patchRow}
|
onPatchRow={patchRow}
|
||||||
/>
|
/>
|
||||||
<WoTableRowServiceCells
|
<WoTableRowServiceCells
|
||||||
|
|
@ -167,14 +187,16 @@ export function WoTableRow({
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
pmTypes={pmTypes}
|
pmTypes={pmTypes}
|
||||||
search={search}
|
search={search}
|
||||||
mutationCellStyle={mutationCellStyle}
|
mutationCellStyle={coreLockStyle}
|
||||||
handlers={handlers}
|
viewOnly={isCoreLocked}
|
||||||
|
canceledLockStyle={canceledLockStyle}
|
||||||
|
handlers={guardedHandlers}
|
||||||
onPatchRow={patchRow}
|
onPatchRow={patchRow}
|
||||||
/>
|
/>
|
||||||
<td style={{ padding: "0 8px" }} onClick={(e) => e.stopPropagation()}>
|
<td style={{ padding: "0 8px" }} onClick={(e) => e.stopPropagation()}>
|
||||||
<WoTableRowActions
|
<WoTableRowActions
|
||||||
onView={() => handlers.onView(row)}
|
onView={() => handlers.onView(row)}
|
||||||
onEdit={isCanceled ? undefined : () => handlers.onEdit(row)}
|
onEdit={closedForEdit ? undefined : () => handlers.onEdit(row)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ type WoTableProps = {
|
||||||
weekMonday: string;
|
weekMonday: string;
|
||||||
onNavigateToDay: (dateIso: string) => void;
|
onNavigateToDay: (dateIso: string) => void;
|
||||||
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
||||||
|
onRequestComplete?: (row: WorkOrderTableRow) => void;
|
||||||
/** Omit until completion-doc slice mounts CompDocDialog. */
|
/** Omit until completion-doc slice mounts CompDocDialog. */
|
||||||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||||
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
||||||
|
|
@ -97,6 +98,7 @@ export function WoTable({
|
||||||
weekMonday,
|
weekMonday,
|
||||||
onNavigateToDay,
|
onNavigateToDay,
|
||||||
onOpenSlideOver,
|
onOpenSlideOver,
|
||||||
|
onRequestComplete,
|
||||||
onOpenCompDoc,
|
onOpenCompDoc,
|
||||||
onOpenUplifts,
|
onOpenUplifts,
|
||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
|
|
@ -121,6 +123,7 @@ export function WoTable({
|
||||||
onNavigateToDay,
|
onNavigateToDay,
|
||||||
onOpenAdvancedFilters,
|
onOpenAdvancedFilters,
|
||||||
onOpenSlideOver,
|
onOpenSlideOver,
|
||||||
|
onRequestComplete,
|
||||||
onOpenCompDoc,
|
onOpenCompDoc,
|
||||||
onOpenUplifts,
|
onOpenUplifts,
|
||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
|
|
||||||
|
type WorkOrderCompleteDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
onDialogClose: (() => void) | undefined;
|
||||||
|
onKeepOpen: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderCompleteDialog({
|
||||||
|
open,
|
||||||
|
isPending,
|
||||||
|
onDialogClose,
|
||||||
|
onKeepOpen,
|
||||||
|
onConfirm,
|
||||||
|
}: WorkOrderCompleteDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onDialogClose} slotProps={dialogBlurBackdropSlotProps}>
|
||||||
|
<DialogTitle>Mark as Complete?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Once marked as complete, this work order cannot be edited anymore.
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button variant="outline" onClick={onKeepOpen} disabled={isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onConfirm} loading={isPending} loadingText="Saving…">
|
||||||
|
Mark as Complete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/det
|
||||||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||||
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
|
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
|
||||||
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
|
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
|
||||||
|
import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog";
|
||||||
import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays";
|
import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays";
|
||||||
import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard";
|
import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard";
|
||||||
import type { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
|
import type { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
|
||||||
|
|
@ -58,6 +59,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
|
||||||
onSave={(id, patch, options) => page.patchField(id, patch, options)}
|
onSave={(id, patch, options) => page.patchField(id, patch, options)}
|
||||||
onOpenCompletionDoc={(row) => openCompletionDoc(row, page.setDocRow)}
|
onOpenCompletionDoc={(row) => openCompletionDoc(row, page.setDocRow)}
|
||||||
onCancelWO={page.setConfirmCancel}
|
onCancelWO={page.setConfirmCancel}
|
||||||
|
onRequestComplete={page.setConfirmComplete}
|
||||||
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
|
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
|
||||||
saving={page.isPatchPending}
|
saving={page.isPatchPending}
|
||||||
onClose={page.handleCloseSlideOver}
|
onClose={page.handleCloseSlideOver}
|
||||||
|
|
@ -71,6 +73,14 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
|
||||||
onConfirm={page.handleConfirmCancel}
|
onConfirm={page.handleConfirmCancel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<WorkOrderCompleteDialog
|
||||||
|
open={Boolean(page.confirmComplete)}
|
||||||
|
isPending={page.isPatchPending}
|
||||||
|
onDialogClose={page.completeDialogClose}
|
||||||
|
onKeepOpen={() => page.setConfirmComplete(null)}
|
||||||
|
onConfirm={page.handleConfirmComplete}
|
||||||
|
/>
|
||||||
|
|
||||||
{page.activeMediaRow != null && (
|
{page.activeMediaRow != null && (
|
||||||
<MediaUploadModal
|
<MediaUploadModal
|
||||||
row={page.activeMediaRow}
|
row={page.activeMediaRow}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||||
|
|
||||||
type WorkOrderDispatchesAccordionProps = {
|
type WorkOrderDispatchesAccordionProps = {
|
||||||
wo: WorkOrderDetail;
|
wo: WorkOrderDetail;
|
||||||
|
vendorViewOnly?: boolean;
|
||||||
onOpenDispatchDetail: (dispatchId: string | number) => void;
|
onOpenDispatchDetail: (dispatchId: string | number) => void;
|
||||||
onOpenVendorEditor: (
|
onOpenVendorEditor: (
|
||||||
event: React.MouseEvent,
|
event: React.MouseEvent,
|
||||||
|
|
@ -27,6 +28,7 @@ type WorkOrderDispatchesAccordionProps = {
|
||||||
|
|
||||||
export function WorkOrderDispatchesAccordion({
|
export function WorkOrderDispatchesAccordion({
|
||||||
wo,
|
wo,
|
||||||
|
vendorViewOnly = false,
|
||||||
onOpenDispatchDetail,
|
onOpenDispatchDetail,
|
||||||
onOpenVendorEditor,
|
onOpenVendorEditor,
|
||||||
}: WorkOrderDispatchesAccordionProps) {
|
}: WorkOrderDispatchesAccordionProps) {
|
||||||
|
|
@ -95,7 +97,7 @@ export function WorkOrderDispatchesAccordion({
|
||||||
}
|
}
|
||||||
disabled={dispatch.vendorId === ""}
|
disabled={dispatch.vendorId === ""}
|
||||||
>
|
>
|
||||||
Edit vendor
|
{vendorViewOnly ? "View vendor" : "Edit vendor"}
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Button } from "@mui/material";
|
||||||
|
import type { VendorEditorState } from "./work-order-vendor-editor-types";
|
||||||
|
import { isVendorEditorValid } from "./work-order-vendor-editor-utils";
|
||||||
|
|
||||||
|
type WorkOrderVendorEditorActionsProps = {
|
||||||
|
viewOnly: boolean;
|
||||||
|
editor: VendorEditorState | null;
|
||||||
|
loading: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderVendorEditorActions({
|
||||||
|
viewOnly,
|
||||||
|
editor,
|
||||||
|
loading,
|
||||||
|
isSaving,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: WorkOrderVendorEditorActionsProps) {
|
||||||
|
if (viewOnly) {
|
||||||
|
return <Button onClick={onClose}>Close</Button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={onClose} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!editor || loading || isSaving || !isVendorEditorValid(editor)}
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import type { VendorEditorState } from "./work-order-vendor-editor-types";
|
import type { VendorEditorState } from "./work-order-vendor-editor-types";
|
||||||
import { formatPhoneMask, isVendorEditorValid } from "./work-order-vendor-editor-utils";
|
import { WorkOrderVendorEditorActions } from "./work-order-vendor-editor-actions";
|
||||||
|
import { WorkOrderVendorEditorFields } from "./work-order-vendor-editor-fields";
|
||||||
|
|
||||||
type WorkOrderVendorEditorDialogProps = {
|
type WorkOrderVendorEditorDialogProps = {
|
||||||
editor: VendorEditorState | null;
|
editor: VendorEditorState | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
|
viewOnly?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onEditorChange: (
|
onEditorChange: (
|
||||||
|
|
@ -31,13 +31,14 @@ export function WorkOrderVendorEditorDialog({
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
isSaving,
|
isSaving,
|
||||||
|
viewOnly = false,
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
onEditorChange,
|
onEditorChange,
|
||||||
}: WorkOrderVendorEditorDialogProps) {
|
}: WorkOrderVendorEditorDialogProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={editor !== null} onClose={onClose} fullWidth maxWidth="sm">
|
<Dialog open={editor !== null} onClose={onClose} fullWidth maxWidth="sm">
|
||||||
<DialogTitle>Edit assigned vendor</DialogTitle>
|
<DialogTitle>{viewOnly ? "Assigned vendor" : "Edit assigned vendor"}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Stack spacing={2} className="pt-2">
|
<Stack spacing={2} className="pt-2">
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
|
@ -49,65 +50,23 @@ export function WorkOrderVendorEditorDialog({
|
||||||
<CircularProgress size={28} />
|
<CircularProgress size={28} />
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<WorkOrderVendorEditorFields
|
||||||
<TextField
|
editor={editor}
|
||||||
label="Technician name"
|
disabled={viewOnly}
|
||||||
value={editor?.contactName ?? ""}
|
onEditorChange={onEditorChange}
|
||||||
onChange={(event) =>
|
/>
|
||||||
onEditorChange((current) =>
|
|
||||||
current ? { ...current, contactName: event.target.value } : current,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
slotProps={{ htmlInput: { maxLength: 200 } }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Phone"
|
|
||||||
value={editor?.phone ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
onEditorChange((current) =>
|
|
||||||
current ? { ...current, phone: formatPhoneMask(event.target.value) } : current,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
error={Boolean(editor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone))}
|
|
||||||
helperText="Use (XXX) XXX-XXXX"
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Email"
|
|
||||||
type="email"
|
|
||||||
value={editor?.email ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
onEditorChange((current) =>
|
|
||||||
current ? { ...current, email: event.target.value } : current,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Notes"
|
|
||||||
multiline
|
|
||||||
minRows={3}
|
|
||||||
value={editor?.notes ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
onEditorChange((current) =>
|
|
||||||
current ? { ...current, notes: event.target.value } : current,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
slotProps={{ htmlInput: { maxLength: 2000 } }}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={onClose} disabled={isSaving}>
|
<WorkOrderVendorEditorActions
|
||||||
Cancel
|
viewOnly={viewOnly}
|
||||||
</Button>
|
editor={editor}
|
||||||
<Button
|
loading={loading}
|
||||||
variant="contained"
|
isSaving={isSaving}
|
||||||
onClick={onSave}
|
onClose={onClose}
|
||||||
disabled={!editor || loading || isSaving || !isVendorEditorValid(editor)}
|
onSave={onSave}
|
||||||
>
|
/>
|
||||||
{isSaving ? "Saving..." : "Save"}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { TextField } from "@mui/material";
|
||||||
|
import type { VendorEditorState } from "./work-order-vendor-editor-types";
|
||||||
|
import { formatPhoneMask } from "./work-order-vendor-editor-utils";
|
||||||
|
|
||||||
|
type VendorEditorChange = (
|
||||||
|
updater: (current: VendorEditorState | null) => VendorEditorState | null,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
type WorkOrderVendorEditorFieldsProps = {
|
||||||
|
editor: VendorEditorState | null;
|
||||||
|
disabled: boolean;
|
||||||
|
onEditorChange: VendorEditorChange;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderVendorEditorFields({
|
||||||
|
editor,
|
||||||
|
disabled,
|
||||||
|
onEditorChange,
|
||||||
|
}: WorkOrderVendorEditorFieldsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
label="Technician name"
|
||||||
|
value={editor?.contactName ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
onEditorChange((current) =>
|
||||||
|
current ? { ...current, contactName: event.target.value } : current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
slotProps={{ htmlInput: { maxLength: 200 } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Phone"
|
||||||
|
value={editor?.phone ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
onEditorChange((current) =>
|
||||||
|
current ? { ...current, phone: formatPhoneMask(event.target.value) } : current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
error={Boolean(editor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone))}
|
||||||
|
helperText="Use (XXX) XXX-XXXX"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={editor?.email ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
onEditorChange((current) =>
|
||||||
|
current ? { ...current, email: event.target.value } : current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Notes"
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
value={editor?.notes ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
onEditorChange((current) =>
|
||||||
|
current ? { ...current, notes: event.target.value } : current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
slotProps={{ htmlInput: { maxLength: 2000 } }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ type WorkOrderViewDetailsProps = {
|
||||||
onAssignAnchorChange: (anchor: HTMLElement | null) => void;
|
onAssignAnchorChange: (anchor: HTMLElement | null) => void;
|
||||||
onAssign: (userId: string | number, userName: string) => void;
|
onAssign: (userId: string | number, userName: string) => void;
|
||||||
onUnassign: () => void;
|
onUnassign: () => void;
|
||||||
|
readOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WorkOrderViewDetails({
|
export function WorkOrderViewDetails({
|
||||||
|
|
@ -20,6 +21,7 @@ export function WorkOrderViewDetails({
|
||||||
onAssignAnchorChange,
|
onAssignAnchorChange,
|
||||||
onAssign,
|
onAssign,
|
||||||
onUnassign,
|
onUnassign,
|
||||||
|
readOnly = false,
|
||||||
}: WorkOrderViewDetailsProps) {
|
}: WorkOrderViewDetailsProps) {
|
||||||
return (
|
return (
|
||||||
<Paper variant="outlined" className="p-4">
|
<Paper variant="outlined" className="p-4">
|
||||||
|
|
@ -31,40 +33,44 @@ export function WorkOrderViewDetails({
|
||||||
<WorkOrderDetailField label="Customer WO" value={wo.workOrderNumber || "—"} />
|
<WorkOrderDetailField label="Customer WO" value={wo.workOrderNumber || "—"} />
|
||||||
<WorkOrderDetailField label="Priority" value={wo.priority || "—"} />
|
<WorkOrderDetailField label="Priority" value={wo.priority || "—"} />
|
||||||
<WorkOrderDetailField label="Location" value={wo.location || "—"} />
|
<WorkOrderDetailField label="Location" value={wo.location || "—"} />
|
||||||
<Box>
|
{readOnly ? (
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
|
<WorkOrderDetailField label="Assigned To" value={wo.assignedTo || "Unassigned"} />
|
||||||
Assigned To
|
) : (
|
||||||
</Typography>
|
<Box>
|
||||||
<Button size="small" onClick={(event) => onAssignAnchorChange(event.currentTarget)}>
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
|
||||||
{wo.assignedTo || "Unassigned"}
|
Assigned To
|
||||||
</Button>
|
</Typography>
|
||||||
<Menu
|
<Button size="small" onClick={(event) => onAssignAnchorChange(event.currentTarget)}>
|
||||||
anchorEl={assignAnchor}
|
{wo.assignedTo || "Unassigned"}
|
||||||
open={Boolean(assignAnchor)}
|
</Button>
|
||||||
onClose={() => onAssignAnchorChange(null)}
|
<Menu
|
||||||
>
|
anchorEl={assignAnchor}
|
||||||
<MenuItem
|
open={Boolean(assignAnchor)}
|
||||||
onClick={() => {
|
onClose={() => onAssignAnchorChange(null)}
|
||||||
onAssignAnchorChange(null);
|
|
||||||
onUnassign();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Unassigned
|
|
||||||
</MenuItem>
|
|
||||||
{users.map((user) => (
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={String(user.id)}
|
|
||||||
selected={wo.assignedTo === user.name}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAssignAnchorChange(null);
|
onAssignAnchorChange(null);
|
||||||
onAssign(user.id, user.name);
|
onUnassign();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{user.name}
|
Unassigned
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
{users.map((user) => (
|
||||||
</Menu>
|
<MenuItem
|
||||||
</Box>
|
key={String(user.id)}
|
||||||
|
selected={wo.assignedTo === user.name}
|
||||||
|
onClick={() => {
|
||||||
|
onAssignAnchorChange(null);
|
||||||
|
onAssign(user.id, user.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<WorkOrderDetailField label="Source" value={wo.source || "—"} />
|
<WorkOrderDetailField label="Source" value={wo.source || "—"} />
|
||||||
<WorkOrderDetailField label="TT" value={wo.tt || "—"} />
|
<WorkOrderDetailField label="TT" value={wo.tt || "—"} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,7 @@
|
||||||
import { Link as RouterLink, useNavigate } from "react-router";
|
import { Link as RouterLink, useNavigate } from "react-router";
|
||||||
import { Breadcrumbs, Button, Chip, Link, Menu, MenuItem, Stack, Typography } from "@mui/material";
|
import { Breadcrumbs, Button, Link, Stack, Typography } from "@mui/material";
|
||||||
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
|
|
||||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||||
import {
|
import { WorkOrderViewStatusControl } from "./work-order-view-status-control";
|
||||||
detailToClosabilityInput,
|
|
||||||
formatClosabilityMissingTitle,
|
|
||||||
getClosabilityGaps,
|
|
||||||
isCompletionStatusLabel,
|
|
||||||
} from "@/domain/work-orders/utils/wo-closability";
|
|
||||||
|
|
||||||
const STATUS_COLORS: Record<
|
|
||||||
string,
|
|
||||||
"default" | "primary" | "secondary" | "error" | "info" | "success" | "warning"
|
|
||||||
> = {
|
|
||||||
Open: "primary",
|
|
||||||
"In Progress": "info",
|
|
||||||
"On Hold": "warning",
|
|
||||||
Done: "success",
|
|
||||||
Cancelled: "default",
|
|
||||||
};
|
|
||||||
|
|
||||||
const CLOSABILITY_UNAVAILABLE_TITLE = "Unable to verify completion requirements";
|
|
||||||
|
|
||||||
type WorkOrderViewHeaderProps = {
|
type WorkOrderViewHeaderProps = {
|
||||||
wo: WorkOrderDetail;
|
wo: WorkOrderDetail;
|
||||||
|
|
@ -31,6 +12,7 @@ type WorkOrderViewHeaderProps = {
|
||||||
onStatusAnchorChange: (anchor: HTMLElement | null) => void;
|
onStatusAnchorChange: (anchor: HTMLElement | null) => void;
|
||||||
onStatusChange: (status: string) => void;
|
onStatusChange: (status: string) => void;
|
||||||
onOpenDispatch: () => void;
|
onOpenDispatch: () => void;
|
||||||
|
readOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WorkOrderViewHeader({
|
export function WorkOrderViewHeader({
|
||||||
|
|
@ -41,9 +23,9 @@ export function WorkOrderViewHeader({
|
||||||
onStatusAnchorChange,
|
onStatusAnchorChange,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onOpenDispatch,
|
onOpenDispatch,
|
||||||
|
readOnly = false,
|
||||||
}: WorkOrderViewHeaderProps) {
|
}: WorkOrderViewHeaderProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const closabilityGaps = getClosabilityGaps(detailToClosabilityInput(wo));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -62,54 +44,25 @@ export function WorkOrderViewHeader({
|
||||||
<Typography variant="h5" component="h1">
|
<Typography variant="h5" component="h1">
|
||||||
{wo.title}
|
{wo.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip
|
<WorkOrderViewStatusControl
|
||||||
label={wo.status}
|
wo={wo}
|
||||||
color={STATUS_COLORS[wo.status] ?? "default"}
|
readOnly={readOnly}
|
||||||
onClick={(event) => onStatusAnchorChange(event.currentTarget)}
|
isClosabilityReady={isClosabilityReady}
|
||||||
className="w-fit cursor-pointer"
|
statusAnchor={statusAnchor}
|
||||||
|
onStatusAnchorChange={onStatusAnchorChange}
|
||||||
|
onStatusChange={onStatusChange}
|
||||||
/>
|
/>
|
||||||
<Menu
|
|
||||||
anchorEl={statusAnchor}
|
|
||||||
open={Boolean(statusAnchor)}
|
|
||||||
onClose={() => onStatusAnchorChange(null)}
|
|
||||||
>
|
|
||||||
{WORK_ORDER_STATUS_OPTIONS.map((status) => {
|
|
||||||
const isCompletionStatus = isCompletionStatusLabel(status);
|
|
||||||
const isCompletedBlocked =
|
|
||||||
isCompletionStatus && (!isClosabilityReady || closabilityGaps.length > 0);
|
|
||||||
const blockedTitle = !isClosabilityReady
|
|
||||||
? CLOSABILITY_UNAVAILABLE_TITLE
|
|
||||||
: formatClosabilityMissingTitle(closabilityGaps);
|
|
||||||
return (
|
|
||||||
<MenuItem
|
|
||||||
key={status}
|
|
||||||
selected={status === wo.status}
|
|
||||||
disabled={isCompletedBlocked}
|
|
||||||
title={isCompletedBlocked ? blockedTitle : undefined}
|
|
||||||
onClick={() => {
|
|
||||||
if (isCompletedBlocked) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onStatusAnchorChange(null);
|
|
||||||
if (status !== wo.status) {
|
|
||||||
onStatusChange(status);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Menu>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
|
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
|
||||||
<Button variant="contained" color="success" onClick={onOpenDispatch}>
|
<Button variant="contained" color="success" onClick={onOpenDispatch}>
|
||||||
Dispatch
|
Dispatch
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="contained" onClick={() => navigate(`/workorders/edit/${workOrderId}`)}>
|
{Boolean(!readOnly) && (
|
||||||
Edit
|
<Button variant="contained" onClick={() => navigate(`/workorders/edit/${workOrderId}`)}>
|
||||||
</Button>
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outlined" onClick={() => navigate("/workorders")}>
|
<Button variant="outlined" onClick={() => navigate("/workorders")}>
|
||||||
Back to List
|
Back to List
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { Chip, Menu, MenuItem } from "@mui/material";
|
||||||
|
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||||
|
import {
|
||||||
|
detailToClosabilityInput,
|
||||||
|
formatClosabilityMissingTitle,
|
||||||
|
getClosabilityGaps,
|
||||||
|
isCompletionStatusLabel,
|
||||||
|
} from "@/domain/work-orders/utils/wo-closability";
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<
|
||||||
|
string,
|
||||||
|
"default" | "primary" | "secondary" | "error" | "info" | "success" | "warning"
|
||||||
|
> = {
|
||||||
|
Open: "primary",
|
||||||
|
"In Progress": "info",
|
||||||
|
"On Hold": "warning",
|
||||||
|
Done: "success",
|
||||||
|
Completed: "success",
|
||||||
|
Cancelled: "default",
|
||||||
|
Canceled: "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLOSABILITY_UNAVAILABLE_TITLE = "Unable to verify completion requirements";
|
||||||
|
const LOCKED_STATUS_LABEL = "Status is locked because this work order is completed";
|
||||||
|
|
||||||
|
type WorkOrderViewStatusControlProps = {
|
||||||
|
wo: WorkOrderDetail;
|
||||||
|
readOnly?: boolean;
|
||||||
|
isClosabilityReady: boolean;
|
||||||
|
statusAnchor: HTMLElement | null;
|
||||||
|
onStatusAnchorChange: (anchor: HTMLElement | null) => void;
|
||||||
|
onStatusChange: (status: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderViewStatusControl({
|
||||||
|
wo,
|
||||||
|
readOnly = false,
|
||||||
|
isClosabilityReady,
|
||||||
|
statusAnchor,
|
||||||
|
onStatusAnchorChange,
|
||||||
|
onStatusChange,
|
||||||
|
}: WorkOrderViewStatusControlProps) {
|
||||||
|
const chipColor = STATUS_COLORS[wo.status] ?? "default";
|
||||||
|
|
||||||
|
if (readOnly) {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
label={wo.status}
|
||||||
|
color={chipColor}
|
||||||
|
className="w-fit"
|
||||||
|
aria-disabled
|
||||||
|
aria-label={LOCKED_STATUS_LABEL}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const closabilityGaps = getClosabilityGaps(detailToClosabilityInput(wo));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Chip
|
||||||
|
label={wo.status}
|
||||||
|
color={chipColor}
|
||||||
|
onClick={(event) => onStatusAnchorChange(event.currentTarget)}
|
||||||
|
className="w-fit cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Menu
|
||||||
|
anchorEl={statusAnchor}
|
||||||
|
open={Boolean(statusAnchor)}
|
||||||
|
onClose={() => onStatusAnchorChange(null)}
|
||||||
|
>
|
||||||
|
{WORK_ORDER_STATUS_OPTIONS.map((status) => {
|
||||||
|
const isCompletionStatus = isCompletionStatusLabel(status);
|
||||||
|
const isCompletedBlocked =
|
||||||
|
isCompletionStatus && (!isClosabilityReady || closabilityGaps.length > 0);
|
||||||
|
const blockedTitle = !isClosabilityReady
|
||||||
|
? CLOSABILITY_UNAVAILABLE_TITLE
|
||||||
|
: formatClosabilityMissingTitle(closabilityGaps);
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={status}
|
||||||
|
selected={status === wo.status}
|
||||||
|
disabled={isCompletedBlocked}
|
||||||
|
title={isCompletedBlocked ? blockedTitle : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
if (isCompletedBlocked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onStatusAnchorChange(null);
|
||||||
|
if (status !== wo.status) {
|
||||||
|
onStatusChange(status);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useState } from "react";
|
||||||
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
|
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
@ -19,6 +20,7 @@ import {
|
||||||
wizardDraftToClosabilityInput,
|
wizardDraftToClosabilityInput,
|
||||||
} from "@/domain/work-orders/utils/wo-closability";
|
} from "@/domain/work-orders/utils/wo-closability";
|
||||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||||
|
import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog";
|
||||||
import {
|
import {
|
||||||
DLG_INPUT_CLS,
|
DLG_INPUT_CLS,
|
||||||
DLG_INPUT_STYLE,
|
DLG_INPUT_STYLE,
|
||||||
|
|
@ -115,6 +117,62 @@ function WizardVendorDetail({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WizardApptTimeFieldsProps = Pick<WizardStepVendorTimeProps, "draft" | "onUpdate" | "onPatch">;
|
||||||
|
|
||||||
|
function WizardApptTimeFields({ draft, onUpdate, onPatch }: WizardApptTimeFieldsProps) {
|
||||||
|
const duration = calcDuration(draft.apptStart, draft.apptEnd);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<WizLabel optional>Appt Time</WizLabel>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<WizardFieldSelect
|
||||||
|
value={draft.apptStart}
|
||||||
|
placeholder="Start"
|
||||||
|
icon={
|
||||||
|
<Clock size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||||
|
}
|
||||||
|
search={false}
|
||||||
|
options={TIME_SLOTS.map((t) => ({ key: t, label: fmt12(t) }))}
|
||||||
|
onPick={(t) =>
|
||||||
|
onPatch({
|
||||||
|
apptStart: t,
|
||||||
|
apptEnd: draft.apptEnd && draft.apptEnd <= t ? "" : draft.apptEnd,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="shrink-0 text-[12px]" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
to
|
||||||
|
</span>
|
||||||
|
<WizardFieldSelect
|
||||||
|
value={draft.apptEnd}
|
||||||
|
placeholder="End"
|
||||||
|
icon={
|
||||||
|
<Clock size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||||
|
}
|
||||||
|
search={false}
|
||||||
|
disabled={!draft.apptStart}
|
||||||
|
options={TIME_SLOTS.filter((t) => !draft.apptStart || t > draft.apptStart).map((t) => ({
|
||||||
|
key: t,
|
||||||
|
label: fmt12(t),
|
||||||
|
}))}
|
||||||
|
onPick={(t) => onUpdate("apptEnd", t)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{Boolean(draft.apptStart && draft.apptEnd && duration) && (
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
variant="caption"
|
||||||
|
className="mt-2 flex items-center gap-1.5"
|
||||||
|
sx={{ fontSize: 11.5, color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
<Clock size={12} /> Duration {duration}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function buildWizardStatusOptions(closabilityGaps: string[]) {
|
function buildWizardStatusOptions(closabilityGaps: string[]) {
|
||||||
return ALL_WIZARD_STATUSES.map((status) => {
|
return ALL_WIZARD_STATUSES.map((status) => {
|
||||||
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
|
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
|
||||||
|
|
@ -134,6 +192,7 @@ export function WizardStepVendorTime({
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onPatch,
|
onPatch,
|
||||||
}: WizardStepVendorTimeProps) {
|
}: WizardStepVendorTimeProps) {
|
||||||
|
const [confirmComplete, setConfirmComplete] = useState(false);
|
||||||
const selectedVendor = vendors.find((v) => String(v.id) === draft.vendorId);
|
const selectedVendor = vendors.find((v) => String(v.id) === draft.vendorId);
|
||||||
const statusValue = (draft.status || "Incomplete") as WizardWOStatus;
|
const statusValue = (draft.status || "Incomplete") as WizardWOStatus;
|
||||||
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
|
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
|
||||||
|
|
@ -204,55 +263,7 @@ export function WizardStepVendorTime({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<WizardApptTimeFields draft={draft} onUpdate={onUpdate} onPatch={onPatch} />
|
||||||
<WizLabel optional>Appt Time</WizLabel>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<WizardFieldSelect
|
|
||||||
value={draft.apptStart}
|
|
||||||
placeholder="Start"
|
|
||||||
icon={
|
|
||||||
<Clock size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
|
||||||
}
|
|
||||||
search={false}
|
|
||||||
options={TIME_SLOTS.map((t) => ({ key: t, label: fmt12(t) }))}
|
|
||||||
onPick={(t) =>
|
|
||||||
onPatch({
|
|
||||||
apptStart: t,
|
|
||||||
apptEnd: draft.apptEnd && draft.apptEnd <= t ? "" : draft.apptEnd,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="shrink-0 text-[12px]" style={{ color: "var(--muted-foreground)" }}>
|
|
||||||
to
|
|
||||||
</span>
|
|
||||||
<WizardFieldSelect
|
|
||||||
value={draft.apptEnd}
|
|
||||||
placeholder="End"
|
|
||||||
icon={
|
|
||||||
<Clock size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
|
||||||
}
|
|
||||||
search={false}
|
|
||||||
disabled={!draft.apptStart}
|
|
||||||
options={TIME_SLOTS.filter((t) => !draft.apptStart || t > draft.apptStart).map((t) => ({
|
|
||||||
key: t,
|
|
||||||
label: fmt12(t),
|
|
||||||
}))}
|
|
||||||
onPick={(t) => onUpdate("apptEnd", t)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{draft.apptStart !== "" &&
|
|
||||||
draft.apptEnd !== "" &&
|
|
||||||
Boolean(calcDuration(draft.apptStart, draft.apptEnd)) && (
|
|
||||||
<Text
|
|
||||||
as="p"
|
|
||||||
variant="caption"
|
|
||||||
className="mt-2 flex items-center gap-1.5"
|
|
||||||
sx={{ fontSize: 11.5, color: "var(--muted-foreground)" }}
|
|
||||||
>
|
|
||||||
<Clock size={12} /> Duration {calcDuration(draft.apptStart, draft.apptEnd)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<WizLabel>Status</WizLabel>
|
<WizLabel>Status</WizLabel>
|
||||||
|
|
@ -264,10 +275,24 @@ export function WizardStepVendorTime({
|
||||||
options={buildWizardStatusOptions(closabilityGaps)}
|
options={buildWizardStatusOptions(closabilityGaps)}
|
||||||
onPick={(status) => {
|
onPick={(status) => {
|
||||||
if (status === "Completed" && closabilityGaps.length > 0) return;
|
if (status === "Completed" && closabilityGaps.length > 0) return;
|
||||||
|
if (status === "Completed" && draft.status !== "Completed") {
|
||||||
|
setConfirmComplete(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
onUpdate("status", status as WizardWOStatus);
|
onUpdate("status", status as WizardWOStatus);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<WorkOrderCompleteDialog
|
||||||
|
open={confirmComplete}
|
||||||
|
isPending={false}
|
||||||
|
onDialogClose={() => setConfirmComplete(false)}
|
||||||
|
onKeepOpen={() => setConfirmComplete(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
onUpdate("status", "Completed");
|
||||||
|
setConfirmComplete(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-order
|
||||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||||
import { extractMentionIds } from "@/domain/work-orders/utils/comment-mentions";
|
import { extractMentionIds } from "@/domain/work-orders/utils/comment-mentions";
|
||||||
import { enrichDetailClosabilityFromBoard } from "@/domain/work-orders/utils/enrich-detail-closability-from-board";
|
import { enrichDetailClosabilityFromBoard } from "@/domain/work-orders/utils/enrich-detail-closability-from-board";
|
||||||
|
import {
|
||||||
|
isWorkOrderCoreLocked,
|
||||||
|
isWorkOrderFullyLocked,
|
||||||
|
} from "@/domain/work-orders/utils/work-order-lock";
|
||||||
|
|
||||||
export function useWorkOrderViewPage(workOrderId: string) {
|
export function useWorkOrderViewPage(workOrderId: string) {
|
||||||
const { data: detail, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
|
const { data: detail, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
|
||||||
|
|
@ -101,6 +105,7 @@ export function useWorkOrderViewPage(workOrderId: string) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveVendorEditor = () => {
|
const saveVendorEditor = () => {
|
||||||
|
if (isWorkOrderCoreLocked(wo?.status) || isWorkOrderFullyLocked(wo?.status)) return;
|
||||||
if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return;
|
if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return;
|
||||||
const numericWorkOrderId = Number(workOrderId);
|
const numericWorkOrderId = Number(workOrderId);
|
||||||
if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) {
|
if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export function useWorkOrdersListPage() {
|
||||||
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
|
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [slideOverEdit, setSlideOverEdit] = useState(false);
|
const [slideOverEdit, setSlideOverEdit] = useState(false);
|
||||||
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
|
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
|
||||||
|
const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
|
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
|
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [upliftsRow, setUpliftsRow] = useState<WorkOrderTableRow | null>(null);
|
const [upliftsRow, setUpliftsRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
|
|
@ -86,7 +87,14 @@ export function useWorkOrdersListPage() {
|
||||||
handleCloseSlideOver();
|
handleCloseSlideOver();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConfirmComplete = () => {
|
||||||
|
if (!confirmComplete) return;
|
||||||
|
patchField(confirmComplete.id, { status: "Completed" });
|
||||||
|
setConfirmComplete(null);
|
||||||
|
};
|
||||||
|
|
||||||
const cancelDialogClose = guardDialogClose(isPatchPending, () => setConfirmCancel(null));
|
const cancelDialogClose = guardDialogClose(isPatchPending, () => setConfirmCancel(null));
|
||||||
|
const completeDialogClose = guardDialogClose(isPatchPending, () => setConfirmComplete(null));
|
||||||
|
|
||||||
const saveCompletionDoc = async (
|
const saveCompletionDoc = async (
|
||||||
row: WorkOrderTableRow,
|
row: WorkOrderTableRow,
|
||||||
|
|
@ -109,6 +117,8 @@ export function useWorkOrdersListPage() {
|
||||||
slideOverEdit,
|
slideOverEdit,
|
||||||
confirmCancel,
|
confirmCancel,
|
||||||
setConfirmCancel,
|
setConfirmCancel,
|
||||||
|
confirmComplete,
|
||||||
|
setConfirmComplete,
|
||||||
docRow,
|
docRow,
|
||||||
setDocRow,
|
setDocRow,
|
||||||
mediaRow,
|
mediaRow,
|
||||||
|
|
@ -133,7 +143,9 @@ export function useWorkOrdersListPage() {
|
||||||
handleOpenSlideOver,
|
handleOpenSlideOver,
|
||||||
handleCloseSlideOver,
|
handleCloseSlideOver,
|
||||||
handleConfirmCancel,
|
handleConfirmCancel,
|
||||||
|
handleConfirmComplete,
|
||||||
cancelDialogClose,
|
cancelDialogClose,
|
||||||
|
completeDialogClose,
|
||||||
saveCompletionDoc,
|
saveCompletionDoc,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ export default function WorkOrdersListPage() {
|
||||||
weekMonday={filters.weekMonday}
|
weekMonday={filters.weekMonday}
|
||||||
onNavigateToDay={filters.navigateToDay}
|
onNavigateToDay={filters.navigateToDay}
|
||||||
onOpenSlideOver={page.handleOpenSlideOver}
|
onOpenSlideOver={page.handleOpenSlideOver}
|
||||||
|
onRequestComplete={page.setConfirmComplete}
|
||||||
onOpenCompDoc={(row) => openCompletionDoc(row, page.setDocRow)}
|
onOpenCompDoc={(row) => openCompletionDoc(row, page.setDocRow)}
|
||||||
onOpenMedia={page.setMediaRow}
|
onOpenMedia={page.setMediaRow}
|
||||||
onOpenUplifts={page.setUpliftsRow}
|
onOpenUplifts={page.setUpliftsRow}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ export function DotTogglePill({
|
||||||
)}
|
)}
|
||||||
style={{ ...selectedStyle, ...style }}
|
style={{ ...selectedStyle, ...style }}
|
||||||
{...props}
|
{...props}
|
||||||
|
aria-pressed={selected}
|
||||||
>
|
>
|
||||||
<span className="size-[7px] shrink-0 rounded-full" style={{ background: dotColor }} />
|
<span className="size-[7px] shrink-0 rounded-full" style={{ background: dotColor }} />
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ export function TogglePill({
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
aria-pressed={selected}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ export function ToggleSegment({
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
aria-pressed={selected}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ export function appendStatusPmVendorDocChips(
|
||||||
pushSetChip({
|
pushSetChip({
|
||||||
chips,
|
chips,
|
||||||
key: "pm",
|
key: "pm",
|
||||||
group: "PM type",
|
group: "Service",
|
||||||
set: filters.pmTypes,
|
set: filters.pmTypes,
|
||||||
nameOf: (v) => v,
|
nameOf: (v) => v,
|
||||||
clear: (current) => ({ ...current, pmTypes: new Set() }),
|
clear: (current) => ({ ...current, pmTypes: new Set() }),
|
||||||
|
|
|
||||||
52
src/domain/work-orders/utils/work-order-lock.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
export function isWorkOrderCanceled(status: string | null | undefined): boolean {
|
||||||
|
return status === "Canceled" || status === "Cancelled";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorkOrderCompleted(status: string | null | undefined): boolean {
|
||||||
|
return status === "Completed" || status === "Done";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorkOrderCoreLocked(status: string | null | undefined): boolean {
|
||||||
|
return isWorkOrderCompleted(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorkOrderFullyLocked(status: string | null | undefined): boolean {
|
||||||
|
return isWorkOrderCanceled(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canMutateWorkOrderFlag(status: string | null | undefined): boolean {
|
||||||
|
return !isWorkOrderFullyLocked(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canMutateWorkOrderComments(status: string | null | undefined): boolean {
|
||||||
|
return !isWorkOrderFullyLocked(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canMutateWorkOrderExtraDocs(status: string | null | undefined): boolean {
|
||||||
|
return !isWorkOrderFullyLocked(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPLETED_BOARD_PATCH_KEYS = new Set(["flagColor"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Board PATCH policy for Completed: only `flagColor`. Comments and Extra Docs use
|
||||||
|
* dedicated endpoints (`canMutateWorkOrderComments` / `canMutateWorkOrderExtraDocs`).
|
||||||
|
*/
|
||||||
|
export function canApplyWorkOrderTablePatch(
|
||||||
|
status: string | null | undefined,
|
||||||
|
patch: Record<string, unknown>,
|
||||||
|
): boolean {
|
||||||
|
if (isWorkOrderFullyLocked(status)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!isWorkOrderCoreLocked(status)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const keys = Object.keys(patch);
|
||||||
|
return keys.length > 0 && keys.every((key) => COMPLETED_BOARD_PATCH_KEYS.has(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Completion document / completion-media surface — not Extra Docs or comments. */
|
||||||
|
export function isWorkOrderCompletionSurfaceReadOnly(status: string | null | undefined): boolean {
|
||||||
|
return isWorkOrderFullyLocked(status) || isWorkOrderCoreLocked(status);
|
||||||
|
}
|
||||||
|
|
@ -29,9 +29,11 @@ export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): str
|
||||||
return `${Math.round(maxBytes / (1024 * 1024))} MB`;
|
return `${Math.round(maxBytes / (1024 * 1024))} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Backend IsReadOnly: Canceled or Completed. */
|
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
||||||
|
|
||||||
|
/** Completion-doc / completion-media lock: Canceled or Completed. Extra Docs use canMutateWorkOrderExtraDocs. */
|
||||||
export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean {
|
export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean {
|
||||||
return status === "Canceled" || status === "Completed";
|
return isWorkOrderCompletionSurfaceReadOnly(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canDeleteWorkOrderMedia(media: {
|
export function canDeleteWorkOrderMedia(media: {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { fireEvent, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { ServiceNotesDialog } from "@/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog";
|
||||||
|
import { SiteDialog } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
describe("Completed view-only dialogs", () => {
|
||||||
|
it("hides Save on a view-only site dialog", () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<SiteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
value="BK5"
|
||||||
|
locationId="loc-1"
|
||||||
|
sites={[{ id: "loc-1", name: "BK5" }]}
|
||||||
|
viewOnly
|
||||||
|
onSave={onSave}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /^close$/i })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /^save$/i })).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^close$/i }));
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Save on a view-only service notes dialog", () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ServiceNotesDialog
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
pm="HVAC"
|
||||||
|
extraServices={[]}
|
||||||
|
serviceNotes="Check filters"
|
||||||
|
pmTypes={["HVAC"]}
|
||||||
|
viewOnly
|
||||||
|
onSave={onSave}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /^close$/i })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /^save$/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { SlideOverCommentsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab";
|
||||||
|
import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab";
|
||||||
|
import {
|
||||||
|
canMutateWorkOrderComments,
|
||||||
|
canMutateWorkOrderExtraDocs,
|
||||||
|
} from "@/domain/work-orders/utils/work-order-lock";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
describe("Completed comments and extra docs remain writable", () => {
|
||||||
|
it("shows the comment composer when status is Completed", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<SlideOverCommentsTab
|
||||||
|
comments={[]}
|
||||||
|
isReadOnly={!canMutateWorkOrderComments("Completed")}
|
||||||
|
isPending={false}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText("Add comment…")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /post/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps edit on the author's own comment when status is Completed", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<SlideOverCommentsTab
|
||||||
|
comments={[
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
createdDate: "2026-07-20T12:00:00Z",
|
||||||
|
commenttext: "Need a flag on this",
|
||||||
|
commentType: "General",
|
||||||
|
userName: "Alex",
|
||||||
|
authorId: "u1",
|
||||||
|
commenter: "Alex",
|
||||||
|
recordType: "WorkOrder",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
isReadOnly={!canMutateWorkOrderComments("Completed")}
|
||||||
|
isPending={false}
|
||||||
|
currentUserId="u1"
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
onUpdateComment={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /edit comment/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the comment composer when status is Canceled", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<SlideOverCommentsTab
|
||||||
|
comments={[]}
|
||||||
|
isReadOnly={!canMutateWorkOrderComments("Canceled")}
|
||||||
|
isPending={false}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByPlaceholderText("Add comment…")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows extra-docs upload when status is Completed", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<SlideOverExtrasTab
|
||||||
|
uploads={[]}
|
||||||
|
isReadOnly={!canMutateWorkOrderExtraDocs("Completed")}
|
||||||
|
pending={false}
|
||||||
|
onUpload={vi.fn()}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/upload documents/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides extra-docs upload when status is Canceled", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<SlideOverExtrasTab
|
||||||
|
uploads={[]}
|
||||||
|
isReadOnly={!canMutateWorkOrderExtraDocs("Canceled")}
|
||||||
|
pending={false}
|
||||||
|
onUpload={vi.fn()}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText(/upload documents/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { SlideOverFooter } from "@/app/(protected)/workorders/_components/detail/slide-over-footer";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const footerHandlers = {
|
||||||
|
onCancelWO: vi.fn(),
|
||||||
|
onCancelEdit: vi.fn(),
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onEdit: vi.fn(),
|
||||||
|
onSave: vi.fn(),
|
||||||
|
onDiscard: vi.fn(),
|
||||||
|
onKeepEditing: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("SlideOverFooter completed lock", () => {
|
||||||
|
it("hides Edit and Cancel WO when the work order is completed", () => {
|
||||||
|
const { container } = renderWithProviders(
|
||||||
|
<SlideOverFooter
|
||||||
|
isCancelled={false}
|
||||||
|
isCompleted
|
||||||
|
editing={false}
|
||||||
|
isDirty={false}
|
||||||
|
showUnsaved={false}
|
||||||
|
{...footerHandlers}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
expect(screen.queryByRole("button", { name: /edit wo/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/cancel wo/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Cancel WO and Save when completed while still in edit mode", () => {
|
||||||
|
const { container } = renderWithProviders(
|
||||||
|
<SlideOverFooter
|
||||||
|
isCancelled={false}
|
||||||
|
isCompleted
|
||||||
|
editing
|
||||||
|
isDirty
|
||||||
|
showUnsaved={false}
|
||||||
|
{...footerHandlers}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
expect(screen.queryByText(/cancel wo/i)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /^save$/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the unsaved Save bar when the work order is completed", () => {
|
||||||
|
const { container } = renderWithProviders(
|
||||||
|
<SlideOverFooter
|
||||||
|
isCancelled={false}
|
||||||
|
isCompleted
|
||||||
|
editing
|
||||||
|
isDirty
|
||||||
|
showUnsaved
|
||||||
|
{...footerHandlers}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
expect(screen.queryByText(/unsaved changes/i)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /^save$/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -106,7 +106,7 @@ describe("StatusCell pending uplift closability", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows Completed when no pending uplift", () => {
|
it("does not patch Completed when confirmation callback is omitted", () => {
|
||||||
const onChangeStatus = vi.fn();
|
const onChangeStatus = vi.fn();
|
||||||
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
|
|
@ -117,7 +117,28 @@ describe("StatusCell pending uplift closability", () => {
|
||||||
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
||||||
|
|
||||||
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
|
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows Completed when no pending uplift", () => {
|
||||||
|
const onChangeStatus = vi.fn();
|
||||||
|
const onRequestComplete = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<StatusCell
|
||||||
|
wo={makeRow()}
|
||||||
|
onChangeStatus={onChangeStatus}
|
||||||
|
onOpenSlideOver={vi.fn()}
|
||||||
|
onRequestComplete={onRequestComplete}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
||||||
|
|
||||||
|
expect(onRequestComplete).toHaveBeenCalled();
|
||||||
|
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables Completed when company is missing", () => {
|
it("disables Completed when company is missing", () => {
|
||||||
|
|
@ -143,12 +164,14 @@ describe("StatusCell pending uplift closability", () => {
|
||||||
|
|
||||||
it("allows Completed when vendorId is set even if company label is empty", () => {
|
it("allows Completed when vendorId is set even if company label is empty", () => {
|
||||||
const onChangeStatus = vi.fn();
|
const onChangeStatus = vi.fn();
|
||||||
|
const onRequestComplete = vi.fn();
|
||||||
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<StatusCell
|
<StatusCell
|
||||||
wo={makeRow({ company: "", vendorId: "7", tech: "" })}
|
wo={makeRow({ company: "", vendorId: "7", tech: "" })}
|
||||||
onChangeStatus={onChangeStatus}
|
onChangeStatus={onChangeStatus}
|
||||||
onOpenSlideOver={vi.fn()}
|
onOpenSlideOver={vi.fn()}
|
||||||
|
onRequestComplete={onRequestComplete}
|
||||||
/>,
|
/>,
|
||||||
{ withAuth: false },
|
{ withAuth: false },
|
||||||
);
|
);
|
||||||
|
|
@ -156,17 +179,20 @@ describe("StatusCell pending uplift closability", () => {
|
||||||
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
||||||
|
|
||||||
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
|
expect(onRequestComplete).toHaveBeenCalled();
|
||||||
|
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows Completed when vendorId has an empty company label and a live dispatch status", () => {
|
it("allows Completed when vendorId has an empty company label and a live dispatch status", () => {
|
||||||
const onChangeStatus = vi.fn();
|
const onChangeStatus = vi.fn();
|
||||||
|
const onRequestComplete = vi.fn();
|
||||||
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<StatusCell
|
<StatusCell
|
||||||
wo={makeRow({ company: "", vendorId: "7", tech: "", primaryDispatchStatus: "Sent" })}
|
wo={makeRow({ company: "", vendorId: "7", tech: "", primaryDispatchStatus: "Sent" })}
|
||||||
onChangeStatus={onChangeStatus}
|
onChangeStatus={onChangeStatus}
|
||||||
onOpenSlideOver={vi.fn()}
|
onOpenSlideOver={vi.fn()}
|
||||||
|
onRequestComplete={onRequestComplete}
|
||||||
/>,
|
/>,
|
||||||
{ withAuth: false },
|
{ withAuth: false },
|
||||||
);
|
);
|
||||||
|
|
@ -174,7 +200,8 @@ describe("StatusCell pending uplift closability", () => {
|
||||||
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
||||||
|
|
||||||
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
|
expect(onRequestComplete).toHaveBeenCalled();
|
||||||
|
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["Cancelled", "Canceled", "Refused"] as const)(
|
it.each(["Cancelled", "Canceled", "Refused"] as const)(
|
||||||
|
|
@ -232,6 +259,7 @@ describe("StatusCell pending uplift closability", () => {
|
||||||
|
|
||||||
it("allows Completed after a vendor patch clears leftover refused status", () => {
|
it("allows Completed after a vendor patch clears leftover refused status", () => {
|
||||||
const onChangeStatus = vi.fn();
|
const onChangeStatus = vi.fn();
|
||||||
|
const onRequestComplete = vi.fn();
|
||||||
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<StatusCell
|
<StatusCell
|
||||||
|
|
@ -243,6 +271,7 @@ describe("StatusCell pending uplift closability", () => {
|
||||||
})}
|
})}
|
||||||
onChangeStatus={onChangeStatus}
|
onChangeStatus={onChangeStatus}
|
||||||
onOpenSlideOver={vi.fn()}
|
onOpenSlideOver={vi.fn()}
|
||||||
|
onRequestComplete={onRequestComplete}
|
||||||
/>,
|
/>,
|
||||||
{ withAuth: false },
|
{ withAuth: false },
|
||||||
);
|
);
|
||||||
|
|
@ -250,24 +279,47 @@ describe("StatusCell pending uplift closability", () => {
|
||||||
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
||||||
|
|
||||||
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
|
expect(onRequestComplete).toHaveBeenCalled();
|
||||||
|
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows Completed when technician is empty if company is set", () => {
|
it("does not open the status menu when readOnly", () => {
|
||||||
const onChangeStatus = vi.fn();
|
const onChangeStatus = vi.fn();
|
||||||
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<StatusCell
|
<StatusCell
|
||||||
wo={makeRow({ tech: "" })}
|
wo={makeRow({ status: "Completed" })}
|
||||||
|
readOnly
|
||||||
onChangeStatus={onChangeStatus}
|
onChangeStatus={onChangeStatus}
|
||||||
onOpenSlideOver={vi.fn()}
|
onOpenSlideOver={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
{ withAuth: false },
|
{ withAuth: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("Status is locked because this work order is completed"));
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /scheduled/i })).not.toBeInTheDocument();
|
||||||
|
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows Completed when technician is empty if company is set", () => {
|
||||||
|
const onChangeStatus = vi.fn();
|
||||||
|
const onRequestComplete = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<StatusCell
|
||||||
|
wo={makeRow({ tech: "" })}
|
||||||
|
onChangeStatus={onChangeStatus}
|
||||||
|
onOpenSlideOver={vi.fn()}
|
||||||
|
onRequestComplete={onRequestComplete}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
||||||
|
|
||||||
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
|
expect(onRequestComplete).toHaveBeenCalled();
|
||||||
|
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { useSlideOverEditState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-edit-state";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
|
return {
|
||||||
|
id: 100,
|
||||||
|
woNumber: "100",
|
||||||
|
site: "SITE",
|
||||||
|
type: "Emergency",
|
||||||
|
severity: null,
|
||||||
|
dispatcherId: "d1",
|
||||||
|
dispatcherName: "Alex",
|
||||||
|
scheduledOn: "2026-07-16",
|
||||||
|
completedDate: "",
|
||||||
|
targetWeek: "",
|
||||||
|
dayGroup: "2026-07-16",
|
||||||
|
dayLabel: "Thu",
|
||||||
|
location: "Main",
|
||||||
|
locationId: 5,
|
||||||
|
pm: "",
|
||||||
|
serviceNotes: "",
|
||||||
|
extraServices: [],
|
||||||
|
vendorId: "v1",
|
||||||
|
company: "Acme",
|
||||||
|
tech: "Sam",
|
||||||
|
techPhone: "",
|
||||||
|
apptStart: "",
|
||||||
|
apptEnd: "",
|
||||||
|
apptTime: "",
|
||||||
|
status: "Incomplete",
|
||||||
|
docStatus: "",
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
|
dueDate: "",
|
||||||
|
rescheduleCount: 0,
|
||||||
|
carriedOver: 0,
|
||||||
|
originalWeek: "",
|
||||||
|
originalDate: "",
|
||||||
|
isPastDue: false,
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
mediaCount: 0,
|
||||||
|
title: "",
|
||||||
|
priority: "",
|
||||||
|
primaryDispatchId: null,
|
||||||
|
rowVersion: "1",
|
||||||
|
dispatchRowVersion: "",
|
||||||
|
flagColor: null,
|
||||||
|
isAddOn: false,
|
||||||
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useSlideOverEditState completed lock", () => {
|
||||||
|
it("leaves edit mode and refuses commit when the same work order becomes completed", () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const setTab = vi.fn();
|
||||||
|
const open = baseRow();
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ row, infoSource }) =>
|
||||||
|
useSlideOverEditState({
|
||||||
|
row,
|
||||||
|
editMode: true,
|
||||||
|
infoSource,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
closeDisabled: false,
|
||||||
|
setTab,
|
||||||
|
}),
|
||||||
|
{ initialProps: { row: open, infoSource: open } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.editing).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleDraftChange({ woNumber: "WO-LOCKED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const completed = baseRow({ status: "Completed", woNumber: "100" });
|
||||||
|
rerender({ row: completed, infoSource: completed });
|
||||||
|
|
||||||
|
expect(result.current.editing).toBe(false);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.commit();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { fireEvent, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { WoTableRow } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
|
||||||
|
import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
|
return {
|
||||||
|
id: 9,
|
||||||
|
woNumber: "24818000001",
|
||||||
|
site: "BK5",
|
||||||
|
type: "PM",
|
||||||
|
severity: null,
|
||||||
|
dispatcherId: "u1",
|
||||||
|
dispatcherName: "Alex",
|
||||||
|
scheduledOn: "2026-07-15",
|
||||||
|
completedDate: "2026-07-20",
|
||||||
|
targetWeek: "2026-07-15",
|
||||||
|
dayGroup: "07/15",
|
||||||
|
dayLabel: "Tue",
|
||||||
|
location: "Lobby",
|
||||||
|
locationId: "loc-1",
|
||||||
|
pm: "HVAC",
|
||||||
|
serviceNotes: "",
|
||||||
|
extraServices: [],
|
||||||
|
vendorId: "7",
|
||||||
|
company: "Acme",
|
||||||
|
tech: "Sam",
|
||||||
|
techPhone: "",
|
||||||
|
apptStart: "",
|
||||||
|
apptEnd: "",
|
||||||
|
apptTime: "09:00 – 10:00",
|
||||||
|
status: "Completed",
|
||||||
|
docStatus: "Yes",
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
pocNotes: "",
|
||||||
|
dueDate: "2026-07-22",
|
||||||
|
rescheduleCount: 0,
|
||||||
|
carriedOver: 0,
|
||||||
|
originalWeek: "",
|
||||||
|
originalDate: "",
|
||||||
|
isPastDue: false,
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
isAddOn: false,
|
||||||
|
mediaCount: 1,
|
||||||
|
title: "",
|
||||||
|
priority: "",
|
||||||
|
primaryDispatchId: null,
|
||||||
|
rowVersion: "",
|
||||||
|
dispatchRowVersion: "",
|
||||||
|
flagColor: null,
|
||||||
|
additionalContacts: [],
|
||||||
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlers: WoTableRowHandlers = {
|
||||||
|
onRowClick: vi.fn(),
|
||||||
|
onView: vi.fn(),
|
||||||
|
onEdit: vi.fn(),
|
||||||
|
onOpenMedia: vi.fn(),
|
||||||
|
onPatch: vi.fn(),
|
||||||
|
onScheduleDate: vi.fn(),
|
||||||
|
onScheduleWeek: vi.fn(),
|
||||||
|
onOpenSlideOver: vi.fn(),
|
||||||
|
onOpenCompDoc: vi.fn(),
|
||||||
|
onOpenUplifts: vi.fn(),
|
||||||
|
onReorder: vi.fn(),
|
||||||
|
onDragStart: vi.fn(),
|
||||||
|
onDragOver: vi.fn(),
|
||||||
|
onDrop: vi.fn(),
|
||||||
|
onDragEnd: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("WoTableRow completed selective lock", () => {
|
||||||
|
it("keeps Flag enabled and hides Edit on a Completed row", () => {
|
||||||
|
const row = makeRow();
|
||||||
|
renderWithProviders(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<WoTableRow
|
||||||
|
row={row}
|
||||||
|
allRows={[row]}
|
||||||
|
users={[]}
|
||||||
|
sites={[]}
|
||||||
|
vendors={[]}
|
||||||
|
pmTypes={["HVAC"]}
|
||||||
|
isSelected={false}
|
||||||
|
isFlashing={false}
|
||||||
|
dragViz={{ dragId: null, overId: null }}
|
||||||
|
handlers={handlers}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTitle("Add color flag")).toBeEnabled();
|
||||||
|
fireEvent.click(screen.getByTitle("Add color flag"));
|
||||||
|
expect(screen.getByText("Clear flag")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTitle("Edit")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTitle("View details")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open the status menu on a Completed row", () => {
|
||||||
|
const row = makeRow();
|
||||||
|
renderWithProviders(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<WoTableRow
|
||||||
|
row={row}
|
||||||
|
allRows={[row]}
|
||||||
|
users={[]}
|
||||||
|
sites={[]}
|
||||||
|
vendors={[]}
|
||||||
|
pmTypes={["HVAC"]}
|
||||||
|
isSelected={false}
|
||||||
|
isFlashing={false}
|
||||||
|
dragViz={{ dragId: null, overId: null }}
|
||||||
|
handlers={handlers}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("Status is locked because this work order is completed"));
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /scheduled/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /in progress/i })).not.toBeInTheDocument();
|
||||||
|
expect(handlers.onPatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -120,4 +120,33 @@ describe("WoTable search empty vs Unassigned queue (SH-165/SH-241)", () => {
|
||||||
expect(queryByText("No work orders match your search")).not.toBeInTheDocument();
|
expect(queryByText("No work orders match your search")).not.toBeInTheDocument();
|
||||||
expect(getByText("queue unavailable")).toBeInTheDocument();
|
expect(getByText("queue unavailable")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows empty advanced-filter copy with Adjust filters", () => {
|
||||||
|
const onOpenAdvancedFilters = vi.fn();
|
||||||
|
const { getByText, getByRole } = renderWithProviders(
|
||||||
|
<WoTable
|
||||||
|
tableData={tableData({
|
||||||
|
advActive: true,
|
||||||
|
advResults: [],
|
||||||
|
searchActive: false,
|
||||||
|
noSearchResults: false,
|
||||||
|
})}
|
||||||
|
users={[]}
|
||||||
|
sites={[]}
|
||||||
|
vendors={[]}
|
||||||
|
pmTypes={[]}
|
||||||
|
weekMonday="2026-05-18"
|
||||||
|
onNavigateToDay={vi.fn()}
|
||||||
|
onOpenSlideOver={vi.fn()}
|
||||||
|
onOpenMedia={vi.fn()}
|
||||||
|
onOpenAdvancedFilters={onOpenAdvancedFilters}
|
||||||
|
onPatch={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText("No work orders match these filters")).toBeInTheDocument();
|
||||||
|
getByRole("button", { name: "Adjust filters" }).click();
|
||||||
|
expect(onOpenAdvancedFilters).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { fireEvent, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
describe("WorkOrderCompleteDialog", () => {
|
||||||
|
it("confirms or dismisses without side effects until Mark as Complete", () => {
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
const onKeepOpen = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<WorkOrderCompleteDialog
|
||||||
|
open
|
||||||
|
isPending={false}
|
||||||
|
onDialogClose={undefined}
|
||||||
|
onKeepOpen={onKeepOpen}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Mark as Complete?")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Once marked as complete, this work order cannot be edited anymore."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^cancel$/i }));
|
||||||
|
expect(onKeepOpen).toHaveBeenCalled();
|
||||||
|
expect(onConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onConfirm from Mark as Complete", () => {
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<WorkOrderCompleteDialog
|
||||||
|
open
|
||||||
|
isPending={false}
|
||||||
|
onDialogClose={undefined}
|
||||||
|
onKeepOpen={vi.fn()}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /mark as complete/i }));
|
||||||
|
expect(onConfirm).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { WorkOrderVendorEditorDialog } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog";
|
||||||
|
import { WorkOrderViewDetails } from "@/app/(protected)/workorders/_components/view/work-order-view-details";
|
||||||
|
import { WorkOrderViewHeader } from "@/app/(protected)/workorders/_components/view/work-order-view-header";
|
||||||
|
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||||
|
|
||||||
|
const baseDetail = {
|
||||||
|
id: 1,
|
||||||
|
internalWONumber: "SH-1",
|
||||||
|
workOrderNumber: "24818",
|
||||||
|
title: "Test WO",
|
||||||
|
location: "Lobby",
|
||||||
|
locationId: "loc-1",
|
||||||
|
locationZip: "",
|
||||||
|
assignedTo: "Alex",
|
||||||
|
dueDate: "",
|
||||||
|
createdDate: "",
|
||||||
|
createdDateFull: "",
|
||||||
|
description: "",
|
||||||
|
priority: "",
|
||||||
|
status: "Scheduled",
|
||||||
|
po: "",
|
||||||
|
tt: "",
|
||||||
|
problem: "HVAC",
|
||||||
|
trade: "",
|
||||||
|
subTrade: "",
|
||||||
|
vendorNTE: "",
|
||||||
|
scheduledDate: "2026-07-15",
|
||||||
|
completedDate: "2026-07-20",
|
||||||
|
source: "",
|
||||||
|
type: "PM",
|
||||||
|
docStatus: "Yes",
|
||||||
|
mediaCount: 1,
|
||||||
|
comments: [],
|
||||||
|
auditLog: [],
|
||||||
|
dispatches: [],
|
||||||
|
} as WorkOrderDetail;
|
||||||
|
|
||||||
|
const vendorEditor = {
|
||||||
|
vendorId: 7,
|
||||||
|
vendorName: "Acme",
|
||||||
|
contactName: "Sam",
|
||||||
|
phone: "(317) 555-0100",
|
||||||
|
email: "sam@acme.test",
|
||||||
|
notes: "Gate code 12",
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderHeader(status: string, readOnly: boolean, onStatusChange = vi.fn()) {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<WorkOrderViewHeader
|
||||||
|
wo={{ ...baseDetail, status }}
|
||||||
|
workOrderId="1"
|
||||||
|
isClosabilityReady
|
||||||
|
statusAnchor={null}
|
||||||
|
onStatusAnchorChange={vi.fn()}
|
||||||
|
onStatusChange={onStatusChange}
|
||||||
|
onOpenDispatch={vi.fn()}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("legacy work-order view completed lock", () => {
|
||||||
|
it("hides Edit and the status menu on a Completed work order", () => {
|
||||||
|
const onStatusChange = vi.fn();
|
||||||
|
renderHeader("Completed", true, onStatusChange);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /^edit$/i })).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByLabelText("Status is locked because this work order is completed"));
|
||||||
|
expect(screen.queryByRole("menuitem", { name: /scheduled/i })).not.toBeInTheDocument();
|
||||||
|
expect(onStatusChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Edit and opens the status menu on a Scheduled work order", () => {
|
||||||
|
const onStatusAnchorChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<WorkOrderViewHeader
|
||||||
|
wo={baseDetail}
|
||||||
|
workOrderId="1"
|
||||||
|
isClosabilityReady
|
||||||
|
statusAnchor={null}
|
||||||
|
onStatusAnchorChange={onStatusAnchorChange}
|
||||||
|
onStatusChange={vi.fn()}
|
||||||
|
onOpenDispatch={vi.fn()}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /^edit$/i })).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByText("Scheduled"));
|
||||||
|
expect(onStatusAnchorChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders assignee as text without a menu when read-only", () => {
|
||||||
|
const onAssign = vi.fn();
|
||||||
|
render(
|
||||||
|
<WorkOrderViewDetails
|
||||||
|
wo={{ ...baseDetail, status: "Completed" }}
|
||||||
|
users={[{ id: "u2", name: "Blake" }]}
|
||||||
|
assignAnchor={null}
|
||||||
|
onAssignAnchorChange={vi.fn()}
|
||||||
|
onAssign={onAssign}
|
||||||
|
onUnassign={vi.fn()}
|
||||||
|
readOnly
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Alex")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /alex/i })).not.toBeInTheDocument();
|
||||||
|
expect(onAssign).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the assignee menu on a Scheduled work order", () => {
|
||||||
|
render(
|
||||||
|
<WorkOrderViewDetails
|
||||||
|
wo={baseDetail}
|
||||||
|
users={[{ id: "u2", name: "Blake" }]}
|
||||||
|
assignAnchor={null}
|
||||||
|
onAssignAnchorChange={vi.fn()}
|
||||||
|
onAssign={vi.fn()}
|
||||||
|
onUnassign={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /alex/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Close only and hides Save on the vendor dialog when view-only", () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
render(
|
||||||
|
<WorkOrderVendorEditorDialog
|
||||||
|
editor={vendorEditor}
|
||||||
|
loading={false}
|
||||||
|
error=""
|
||||||
|
isSaving={false}
|
||||||
|
viewOnly
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSave={onSave}
|
||||||
|
onEditorChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /^close$/i })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /^save$/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Technician name")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Save on the vendor dialog when editable", () => {
|
||||||
|
render(
|
||||||
|
<WorkOrderVendorEditorDialog
|
||||||
|
editor={vendorEditor}
|
||||||
|
loading={false}
|
||||||
|
error=""
|
||||||
|
isSaving={false}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
onEditorChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /^save$/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Technician name")).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -140,7 +140,7 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
|
||||||
expect(within(sheet).getByRole("button", { name: /apply filters/i })).toBeInTheDocument();
|
expect(within(sheet).getByRole("button", { name: /apply filters/i })).toBeInTheDocument();
|
||||||
|
|
||||||
// Expand filter sections that start collapsed and assert option inputs are wired.
|
// Expand filter sections that start collapsed and assert option inputs are wired.
|
||||||
for (const label of ["Site", "Assigned to", "PM type", "Vendor / tech"]) {
|
for (const label of ["Site", "Assigned to", "Service", "Vendor / tech"]) {
|
||||||
fireEvent.click(within(sheet).getByRole("button", { name: new RegExp(label, "i") }));
|
fireEvent.click(within(sheet).getByRole("button", { name: new RegExp(label, "i") }));
|
||||||
}
|
}
|
||||||
expect(within(sheet).getByText("Seattle")).toBeInTheDocument();
|
expect(within(sheet).getByText("Seattle")).toBeInTheDocument();
|
||||||
|
|
@ -200,6 +200,62 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
|
||||||
expect(params.dateTo).toBe("2099-12-31");
|
expect(params.dateTo).toBe("2099-12-31");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies Service and Completion doc through the public Advanced Filters sheet", () => {
|
||||||
|
const view = renderWithProviders(<WorkOrdersListPage />, {
|
||||||
|
route: "/workorders",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
|
||||||
|
advOpen = true;
|
||||||
|
view.rerender(<WorkOrdersListPage />);
|
||||||
|
|
||||||
|
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: /^Service/i }));
|
||||||
|
fireEvent.click(within(sheet).getByText("HVAC"));
|
||||||
|
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: /Completion doc/i }));
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: /^Pending$/i }));
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: /^Uploaded$/i }));
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: /^N\/N$/i }));
|
||||||
|
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i }));
|
||||||
|
|
||||||
|
expect(applyAdvancedFilters).toHaveBeenCalled();
|
||||||
|
const appliedCalls = applyAdvancedFilters.mock.calls;
|
||||||
|
const applied = appliedCalls[appliedCalls.length - 1]?.[0] as typeof EMPTY_ADVANCED_FILTERS;
|
||||||
|
expect(applied.pmTypes.has("HVAC")).toBe(true);
|
||||||
|
expect(applied.docs.has("No")).toBe(true);
|
||||||
|
expect(applied.docs.has("Yes")).toBe(true);
|
||||||
|
expect(applied.docs.has("NN")).toBe(true);
|
||||||
|
|
||||||
|
const params = toBoardSearchQueryParams({ filters: applied });
|
||||||
|
expect(params.pmTypes).toEqual(["HVAC"]);
|
||||||
|
expect(params.docStatuses).toEqual([2, 1, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes indicator selected state as aria-pressed", () => {
|
||||||
|
const view = renderWithProviders(<WorkOrdersListPage />, {
|
||||||
|
route: "/workorders",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
|
||||||
|
advOpen = true;
|
||||||
|
view.rerender(<WorkOrdersListPage />);
|
||||||
|
|
||||||
|
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: /Indicators/i }));
|
||||||
|
|
||||||
|
const names = [/Rescheduled ≥ 2/i, /Carried Over ≥ 2/i, /^Add-On$/i];
|
||||||
|
for (const name of names) {
|
||||||
|
const pill = within(sheet).getByRole("button", { name });
|
||||||
|
expect(pill).toHaveAttribute("aria-pressed", "false");
|
||||||
|
fireEvent.click(pill);
|
||||||
|
expect(pill).toHaveAttribute("aria-pressed", "true");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("applies Indicators, Flag, and Internal WO# through the public Advanced Filters sheet", () => {
|
it("applies Indicators, Flag, and Internal WO# through the public Advanced Filters sheet", () => {
|
||||||
const view = renderWithProviders(<WorkOrdersListPage />, {
|
const view = renderWithProviders(<WorkOrdersListPage />, {
|
||||||
route: "/workorders",
|
route: "/workorders",
|
||||||
|
|
@ -216,6 +272,18 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
|
||||||
fireEvent.click(within(sheet).getByRole("button", { name: /Carried Over ≥ 2/i }));
|
fireEvent.click(within(sheet).getByRole("button", { name: /Carried Over ≥ 2/i }));
|
||||||
fireEvent.click(within(sheet).getByRole("button", { name: /^Add-On$/i }));
|
fireEvent.click(within(sheet).getByRole("button", { name: /^Add-On$/i }));
|
||||||
|
|
||||||
|
const rescheduled = within(sheet).getByRole("button", { name: /Rescheduled ≥ 2/i });
|
||||||
|
const carriedOver = within(sheet).getByRole("button", { name: /Carried Over ≥ 2/i });
|
||||||
|
const addOn = within(sheet).getByRole("button", { name: /^Add-On$/i });
|
||||||
|
expect(rescheduled).toHaveAttribute("data-selected", "true");
|
||||||
|
expect(carriedOver).toHaveAttribute("data-selected", "true");
|
||||||
|
expect(addOn).toHaveAttribute("data-selected", "true");
|
||||||
|
expect(rescheduled.style.background).toBe("rgb(245, 243, 255)");
|
||||||
|
expect(rescheduled.style.color).toBe("rgb(124, 58, 237)");
|
||||||
|
expect(carriedOver.style.background).toBe("var(--color-bg-muted)");
|
||||||
|
expect(addOn.style.background).toBe("var(--type-addon-bg)");
|
||||||
|
expect(addOn.style.color).toBe("var(--type-addon-text)");
|
||||||
|
|
||||||
fireEvent.click(within(sheet).getByRole("button", { name: /^Flag$/i }));
|
fireEvent.click(within(sheet).getByRole("button", { name: /^Flag$/i }));
|
||||||
fireEvent.click(within(sheet).getByRole("button", { name: /^Red$/i }));
|
fireEvent.click(within(sheet).getByRole("button", { name: /^Red$/i }));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,24 @@ describe("clearAdvancedFilterGroup", () => {
|
||||||
expect(chips.some((c) => c.key === "uplift" && c.value === "Pending")).toBe(true);
|
expect(chips.some((c) => c.key === "uplift" && c.value === "Pending")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds chips for service, carried over, add-on, and completion doc", () => {
|
||||||
|
const filters = {
|
||||||
|
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
|
||||||
|
pmTypes: new Set(["HVAC"]),
|
||||||
|
carriedOver: true,
|
||||||
|
addOn: true,
|
||||||
|
docs: new Set(["No" as const, "Yes" as const]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const chips = buildAdvancedFilterChips(filters);
|
||||||
|
expect(chips.some((c) => c.key === "pm" && c.group === "Service" && c.value === "HVAC")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(chips.some((c) => c.key === "carriedover" && c.value === "Carried Over")).toBe(true);
|
||||||
|
expect(chips.some((c) => c.key === "addon" && c.value === "Add-On")).toBe(true);
|
||||||
|
expect(chips.some((c) => c.key === "doc" && c.group === "Comp doc")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("clears uplift chip and drops hasUplift with statuses", () => {
|
it("clears uplift chip and drops hasUplift with statuses", () => {
|
||||||
const filters = {
|
const filters = {
|
||||||
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
|
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
|
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
|
||||||
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
|
import {
|
||||||
|
DOC_STATUS_API,
|
||||||
|
LIFECYCLE_STATUS_API,
|
||||||
|
WORK_ORDER_TYPE_API,
|
||||||
|
} from "@/domain/work-orders/types/work-order-board";
|
||||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
|
|
||||||
import {
|
import {
|
||||||
BOARD_SEARCH_MAX_PAGE_SIZE,
|
BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||||
buildBoardSearchParamsRecord,
|
buildBoardSearchParamsRecord,
|
||||||
|
|
@ -176,6 +180,82 @@ describe("toBoardSearchQueryParams", () => {
|
||||||
expect(params.upliftStatuses).toBeUndefined();
|
expect(params.upliftStatuses).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps service types and completion docs onto board search params", () => {
|
||||||
|
const params = toBoardSearchQueryParams({
|
||||||
|
filters: {
|
||||||
|
...EMPTY_ADVANCED_FILTERS,
|
||||||
|
pmTypes: new Set(["HVAC", "Electrical"]),
|
||||||
|
docs: new Set(["No", "Yes", "NN"]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(params.pmTypes).toEqual(["HVAC", "Electrical"]);
|
||||||
|
expect(params.docStatuses).toEqual([
|
||||||
|
DOC_STATUS_API.No,
|
||||||
|
DOC_STATUS_API.Yes,
|
||||||
|
DOC_STATUS_API.NotNeeded,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps each extra advanced-filter facet alone onto board search params", () => {
|
||||||
|
const severity = toBoardSearchQueryParams({
|
||||||
|
filters: { ...EMPTY_ADVANCED_FILTERS, severities: new Set([4]) },
|
||||||
|
});
|
||||||
|
expect(severity.severities).toEqual([4]);
|
||||||
|
expect(severity.carriedOver).toBeUndefined();
|
||||||
|
expect(severity.addOn).toBeUndefined();
|
||||||
|
expect(severity.pmTypes).toBeUndefined();
|
||||||
|
expect(severity.docStatuses).toBeUndefined();
|
||||||
|
|
||||||
|
const carried = toBoardSearchQueryParams({
|
||||||
|
filters: { ...EMPTY_ADVANCED_FILTERS, carriedOver: true },
|
||||||
|
});
|
||||||
|
expect(carried.carriedOver).toBe(true);
|
||||||
|
expect(carried.severities).toBeUndefined();
|
||||||
|
|
||||||
|
const addOn = toBoardSearchQueryParams({
|
||||||
|
filters: { ...EMPTY_ADVANCED_FILTERS, addOn: true },
|
||||||
|
});
|
||||||
|
expect(addOn.addOn).toBe(true);
|
||||||
|
expect(addOn.carriedOver).toBeUndefined();
|
||||||
|
|
||||||
|
const service = toBoardSearchQueryParams({
|
||||||
|
filters: { ...EMPTY_ADVANCED_FILTERS, pmTypes: new Set(["HVAC"]) },
|
||||||
|
});
|
||||||
|
expect(service.pmTypes).toEqual(["HVAC"]);
|
||||||
|
|
||||||
|
const docs = toBoardSearchQueryParams({
|
||||||
|
filters: { ...EMPTY_ADVANCED_FILTERS, docs: new Set(["NN"]) },
|
||||||
|
});
|
||||||
|
expect(docs.docStatuses).toEqual([DOC_STATUS_API.NotNeeded]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combines extra advanced-filter facets with a status facet on the same request", () => {
|
||||||
|
const params = toBoardSearchQueryParams({
|
||||||
|
filters: {
|
||||||
|
...EMPTY_ADVANCED_FILTERS,
|
||||||
|
severities: new Set([1]),
|
||||||
|
carriedOver: true,
|
||||||
|
addOn: true,
|
||||||
|
pmTypes: new Set(["HVAC"]),
|
||||||
|
docs: new Set(["Yes"]),
|
||||||
|
statuses: new Set(["Scheduled"]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(params.severities).toEqual([1]);
|
||||||
|
expect(params.carriedOver).toBe(true);
|
||||||
|
expect(params.addOn).toBe(true);
|
||||||
|
expect(params.pmTypes).toEqual(["HVAC"]);
|
||||||
|
expect(params.docStatuses).toEqual([DOC_STATUS_API.Yes]);
|
||||||
|
expect(params.statuses).toEqual([LIFECYCLE_STATUS_API.Scheduled]);
|
||||||
|
const record = buildBoardSearchParamsRecord(params);
|
||||||
|
expect(record.severities).toEqual([1]);
|
||||||
|
expect(record.carriedOver).toBe(true);
|
||||||
|
expect(record.addOn).toBe(true);
|
||||||
|
expect(record.pmTypes).toEqual(["HVAC"]);
|
||||||
|
expect(record.docStatuses).toEqual([DOC_STATUS_API.Yes]);
|
||||||
|
expect(record.statuses).toEqual([LIFECYCLE_STATUS_API.Scheduled]);
|
||||||
|
});
|
||||||
|
|
||||||
it("maps severity and uplift facets onto board search params", () => {
|
it("maps severity and uplift facets onto board search params", () => {
|
||||||
const params = toBoardSearchQueryParams({
|
const params = toBoardSearchQueryParams({
|
||||||
filters: {
|
filters: {
|
||||||
|
|
|
||||||
49
src/test/domain/work-orders/utils/work-order-lock.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
canApplyWorkOrderTablePatch,
|
||||||
|
canMutateWorkOrderComments,
|
||||||
|
canMutateWorkOrderExtraDocs,
|
||||||
|
canMutateWorkOrderFlag,
|
||||||
|
isWorkOrderCompletionSurfaceReadOnly,
|
||||||
|
isWorkOrderCoreLocked,
|
||||||
|
isWorkOrderFullyLocked,
|
||||||
|
} from "@/domain/work-orders/utils/work-order-lock";
|
||||||
|
|
||||||
|
describe("work-order-lock", () => {
|
||||||
|
it("locks core fields only when Completed", () => {
|
||||||
|
expect(isWorkOrderCoreLocked("Completed")).toBe(true);
|
||||||
|
expect(isWorkOrderCoreLocked("Done")).toBe(true);
|
||||||
|
expect(isWorkOrderCoreLocked("Scheduled")).toBe(false);
|
||||||
|
expect(isWorkOrderCoreLocked("Canceled")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fully locks only canceled statuses", () => {
|
||||||
|
expect(isWorkOrderFullyLocked("Canceled")).toBe(true);
|
||||||
|
expect(isWorkOrderFullyLocked("Cancelled")).toBe(true);
|
||||||
|
expect(isWorkOrderFullyLocked("Completed")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps flag comments and extra docs writable on Completed", () => {
|
||||||
|
expect(canMutateWorkOrderFlag("Completed")).toBe(true);
|
||||||
|
expect(canMutateWorkOrderComments("Completed")).toBe(true);
|
||||||
|
expect(canMutateWorkOrderExtraDocs("Completed")).toBe(true);
|
||||||
|
expect(canMutateWorkOrderFlag("Canceled")).toBe(false);
|
||||||
|
expect(canMutateWorkOrderComments("Canceled")).toBe(false);
|
||||||
|
expect(canMutateWorkOrderExtraDocs("Canceled")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the completion document surface read-only after complete", () => {
|
||||||
|
expect(isWorkOrderCompletionSurfaceReadOnly("Completed")).toBe(true);
|
||||||
|
expect(isWorkOrderCompletionSurfaceReadOnly("Canceled")).toBe(true);
|
||||||
|
expect(isWorkOrderCompletionSurfaceReadOnly("Scheduled")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows only flagColor board patches on Completed", () => {
|
||||||
|
expect(canApplyWorkOrderTablePatch("Completed", { flagColor: "red" })).toBe(true);
|
||||||
|
expect(canApplyWorkOrderTablePatch("Completed", { dispatcherId: "u2" })).toBe(false);
|
||||||
|
expect(canApplyWorkOrderTablePatch("Completed", { status: "Scheduled" })).toBe(false);
|
||||||
|
expect(canApplyWorkOrderTablePatch("Completed", { site: "BK1" })).toBe(false);
|
||||||
|
expect(canApplyWorkOrderTablePatch("Scheduled", { dispatcherId: "u2" })).toBe(true);
|
||||||
|
expect(canApplyWorkOrderTablePatch("Canceled", { flagColor: "red" })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||