mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 07:42:04 +00:00
chore(work-orders): sync feature/wo-detail-comp-doc with current dev
This commit is contained in:
commit
e3aa69e577
132 changed files with 8693 additions and 3084 deletions
59
e2e/vendors/vendor-operations.spec.ts
vendored
59
e2e/vendors/vendor-operations.spec.ts
vendored
|
|
@ -16,9 +16,68 @@ async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||||
|
|
||||||
async function authenticate(page: Page) {
|
async function authenticate(page: Page) {
|
||||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||||
|
await page.route("**/api/Dashboard/Stats", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
openWorkOrders: 0,
|
||||||
|
pendingApprovals: 0,
|
||||||
|
scheduledPMs: 0,
|
||||||
|
activeAssets: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/User*", (route) => fulfillJson(route, { data: [] }));
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Vendor operations", () => {
|
test.describe("Vendor operations", () => {
|
||||||
|
test("keeps the most specific vendor navigation item exclusively current", async ({ page }) => {
|
||||||
|
await authenticate(page);
|
||||||
|
await page.route("**/api/vendor-operations/insights*", (route) =>
|
||||||
|
fulfillJson(route, { data: [] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/vendors/insights");
|
||||||
|
|
||||||
|
const vendorLinks = page.locator('a[href^="/vendors"]');
|
||||||
|
await expect(
|
||||||
|
vendorLinks.filter({ has: page.getByText("Vendor Insights", { exact: true }) }),
|
||||||
|
).toHaveAttribute("aria-current", "page");
|
||||||
|
await expect(
|
||||||
|
vendorLinks.filter({ has: page.getByText("Vendors", { exact: true }) }),
|
||||||
|
).not.toHaveAttribute("aria-current", "page");
|
||||||
|
await expect(
|
||||||
|
vendorLinks.filter({ has: page.getByText("Site Preferences", { exact: true }) }),
|
||||||
|
).not.toHaveAttribute("aria-current", "page");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps Vendor PO controls usable at a constrained viewport", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 700, height: 900 });
|
||||||
|
await authenticate(page);
|
||||||
|
await page.route("**/api/dispatches*", (route) => fulfillJson(route, { items: [], total: 0 }));
|
||||||
|
await page.route("**/api/Vendor/GetVendorList*", (route) =>
|
||||||
|
fulfillJson(route, { Data: [], TotalCount: 0 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/vendor-pos");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Vendor POs" })).toBeVisible();
|
||||||
|
await expect(page.getByText("0 total", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByPlaceholder("Search PO #, dispatch, vendor, WO...")).toBeVisible();
|
||||||
|
await expect(page.locator("label").filter({ hasText: /^Status$/ })).toBeVisible();
|
||||||
|
await expect(page.locator("label").filter({ hasText: /^Vendor$/ })).toBeVisible();
|
||||||
|
await expect(page.getByLabel("From", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByLabel("To", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Clear filters" })).toBeVisible();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page
|
||||||
|
.getByPlaceholder("Search PO #, dispatch, vendor, WO...")
|
||||||
|
.evaluate((element) => element.clientWidth),
|
||||||
|
)
|
||||||
|
.toBeGreaterThanOrEqual(280);
|
||||||
|
});
|
||||||
|
|
||||||
test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({
|
test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
|
||||||
48
e2e/vendors/vendors.spec.ts
vendored
48
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -80,9 +80,19 @@ async function captureVisual(page: Page, path: string) {
|
||||||
|
|
||||||
async function mockVendorApi(
|
async function mockVendorApi(
|
||||||
page: Page,
|
page: Page,
|
||||||
options: { listError?: boolean; deactivationBlocked?: boolean; deleteConflict?: boolean } = {},
|
options: {
|
||||||
|
listError?: boolean;
|
||||||
|
deactivationBlocked?: boolean;
|
||||||
|
deleteConflict?: boolean;
|
||||||
|
legacyGateway?: boolean;
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
const state: MockState = { listUrls: [] };
|
const state: MockState = { listUrls: [] };
|
||||||
|
const vendorRecords = [...activeVendors, ...inactiveVendors].map((vendor) => ({ ...vendor }));
|
||||||
|
if (options.legacyGateway) {
|
||||||
|
vendorRecords[0].Phone = "555-1234 ext 9";
|
||||||
|
vendorRecords[0].GoogleMapsUrl = "http://maps.example.test/legacy";
|
||||||
|
}
|
||||||
|
|
||||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||||
|
|
||||||
|
|
@ -131,7 +141,7 @@ async function mockVendorApi(
|
||||||
}
|
}
|
||||||
|
|
||||||
const isActive = url.searchParams.get("isActive") !== "false";
|
const isActive = url.searchParams.get("isActive") !== "false";
|
||||||
const source = isActive ? activeVendors : inactiveVendors;
|
const source = vendorRecords.filter((vendor) => vendor.IsActive === isActive);
|
||||||
const search = (url.searchParams.get("search") ?? "").toLowerCase();
|
const search = (url.searchParams.get("search") ?? "").toLowerCase();
|
||||||
const company = url.searchParams.get("companies[0]");
|
const company = url.searchParams.get("companies[0]");
|
||||||
const filtered = source.filter(
|
const filtered = source.filter(
|
||||||
|
|
@ -175,8 +185,12 @@ async function mockVendorApi(
|
||||||
const id = route.request().url().split("/").pop() ?? "";
|
const id = route.request().url().split("/").pop() ?? "";
|
||||||
if (route.request().method() === "PUT") {
|
if (route.request().method() === "PUT") {
|
||||||
state.updatedBody = route.request().postDataJSON();
|
state.updatedBody = route.request().postDataJSON();
|
||||||
|
const vendor = vendorRecords.find((item) => String(item.Id) === id);
|
||||||
|
if (options.legacyGateway && vendor && typeof state.updatedBody?.isActive === "boolean") {
|
||||||
|
vendor.IsActive = state.updatedBody.isActive;
|
||||||
|
}
|
||||||
await fulfillJson(route, {
|
await fulfillJson(route, {
|
||||||
data: { ...activeVendors[0], ...state.updatedBody, Id: Number(id) },
|
data: { ...vendor, ...state.updatedBody, Id: Number(id) },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -190,10 +204,12 @@ async function mockVendorApi(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.deletedId = id;
|
state.deletedId = id;
|
||||||
|
const vendor = vendorRecords.find((item) => String(item.Id) === id);
|
||||||
|
if (options.legacyGateway && vendor) vendor.IsActive = false;
|
||||||
await fulfillJson(route, { status: "200" });
|
await fulfillJson(route, { status: "200" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const vendor = [...activeVendors, ...inactiveVendors].find((item) => String(item.Id) === id);
|
const vendor = vendorRecords.find((item) => String(item.Id) === id);
|
||||||
await fulfillJson(route, { data: vendor });
|
await fulfillJson(route, { data: vendor });
|
||||||
});
|
});
|
||||||
await page.route(/\/api\/vendors\/?$/, async (route) => {
|
await page.route(/\/api\/vendors\/?$/, async (route) => {
|
||||||
|
|
@ -336,6 +352,30 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
expect(state.updatedBody?.isActive).toBe(false);
|
expect(state.updatedBody?.isActive).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reactivates a legacy inactive vendor with an exact sparse update", async ({ page }) => {
|
||||||
|
const state = await mockVendorApi(page, { legacyGateway: true });
|
||||||
|
await page.goto("/vendors");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||||
|
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
|
||||||
|
await expect.poll(() => state.deletedId).toBe("1");
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "Inactive (2)" }).click();
|
||||||
|
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
||||||
|
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||||||
|
await detailDrawer.getByRole("button", { name: "Edit" }).click();
|
||||||
|
await expect(detailDrawer.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||||
|
|
||||||
|
await detailDrawer.getByRole("switch", { name: "Inactive" }).click();
|
||||||
|
await detailDrawer.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expect.poll(() => state.updatedBody).toEqual({ isActive: true });
|
||||||
|
await expect(detailDrawer.getByText("Active").first()).toBeVisible();
|
||||||
|
|
||||||
|
await detailDrawer.getByRole("button", { name: "Close drawer" }).click();
|
||||||
|
await page.getByRole("tab", { name: "Active (2)" }).click();
|
||||||
|
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("supports keyboard dismissal and free-text company creation", async ({ page }) => {
|
test("supports keyboard dismissal and free-text company creation", async ({ page }) => {
|
||||||
const state = await mockVendorApi(page);
|
const state = await mockVendorApi(page);
|
||||||
await page.goto("/vendors");
|
await page.goto("/vendors");
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,6 @@
|
||||||
"path": "src/app/(protected)/vendors/index.tsx",
|
"path": "src/app/(protected)/vendors/index.tsx",
|
||||||
"maxLines": 612,
|
"maxLines": 612,
|
||||||
"reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction."
|
"reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction."
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/domain/work-orders/mappers/work-order-mapper.ts",
|
|
||||||
"maxLines": 572,
|
|
||||||
"reason": "API<->domain mapper; long but cohesive mapping logic, pending split by responsibility."
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,7 @@ function maintainabilityGate(files) {
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
return { skipped: true, reason: "no changed governed TS/TSX files" };
|
return { skipped: true, reason: "no changed governed TS/TSX files" };
|
||||||
}
|
}
|
||||||
|
// Invoke eslint via node so Windows (no shebang exec) and Unix both work.
|
||||||
const eslintJs = path.join(ROOT, "node_modules", "eslint", "bin", "eslint.js");
|
const eslintJs = path.join(ROOT, "node_modules", "eslint", "bin", "eslint.js");
|
||||||
const ruleArgs = MAINTAINABILITY_RULES.flatMap((rule) => ["--rule", rule]);
|
const ruleArgs = MAINTAINABILITY_RULES.flatMap((rule) => ["--rule", rule]);
|
||||||
const result = spawnSync(
|
const result = spawnSync(
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,11 @@ export const API_PATHS = {
|
||||||
addDispatchComment: "WorkOrder/AddDispatchComment",
|
addDispatchComment: "WorkOrder/AddDispatchComment",
|
||||||
dispatchToVendor: "WorkOrder/DispatchToVendor",
|
dispatchToVendor: "WorkOrder/DispatchToVendor",
|
||||||
changeStatus: "WorkOrder/ChangeStatus",
|
changeStatus: "WorkOrder/ChangeStatus",
|
||||||
boardDetail: (id: string | number) => `workorders/${id}/detail`,
|
board: "workorders/board",
|
||||||
|
boardCreate: "workorders/board",
|
||||||
|
boardSearch: "workorders/board/search",
|
||||||
boardPatch: (id: string | number) => `workorders/${id}/board`,
|
boardPatch: (id: string | number) => `workorders/${id}/board`,
|
||||||
|
boardDetail: (id: string | number) => `workorders/${id}/detail`,
|
||||||
comments: (id: string | number) => `workorders/${id}/comments`,
|
comments: (id: string | number) => `workorders/${id}/comments`,
|
||||||
commentItem: (workOrderId: string | number, commentId: string | number) =>
|
commentItem: (workOrderId: string | number, commentId: string | number) =>
|
||||||
`workorders/${workOrderId}/comments/${commentId}`,
|
`workorders/${workOrderId}/comments/${commentId}`,
|
||||||
|
|
@ -35,6 +38,7 @@ export const API_PATHS = {
|
||||||
mediaItem: (id: string | number, mediaId: string | number) =>
|
mediaItem: (id: string | number, mediaId: string | number) =>
|
||||||
`workorders/${id}/media/${mediaId}`,
|
`workorders/${id}/media/${mediaId}`,
|
||||||
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
|
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
|
||||||
|
lookupsDispatchers: "workorders/lookups/dispatchers",
|
||||||
},
|
},
|
||||||
pmSchedule: {
|
pmSchedule: {
|
||||||
getList: "PMSchedule/GetList",
|
getList: "PMSchedule/GetList",
|
||||||
|
|
@ -86,6 +90,7 @@ export const API_PATHS = {
|
||||||
delete: "Location/Delete",
|
delete: "Location/Delete",
|
||||||
deleteLocation: "Location/DeleteLocation",
|
deleteLocation: "Location/DeleteLocation",
|
||||||
dropdown: "Location/Dropdown",
|
dropdown: "Location/Dropdown",
|
||||||
|
sites: "locations/sites",
|
||||||
},
|
},
|
||||||
account: {
|
account: {
|
||||||
getList: "Account/GetAccountList",
|
getList: "Account/GetAccountList",
|
||||||
|
|
@ -117,7 +122,7 @@ export const API_PATHS = {
|
||||||
create: "Vendor/Create",
|
create: "Vendor/Create",
|
||||||
update: "Vendor/Update",
|
update: "Vendor/Update",
|
||||||
delete: "Vendor/Delete",
|
delete: "Vendor/Delete",
|
||||||
dropdown: "Vendor/Dropdown",
|
dropdown: "vendors/Dropdown",
|
||||||
facets: "Vendor/facets",
|
facets: "Vendor/facets",
|
||||||
deactivationImpact: "deactivation-impact",
|
deactivationImpact: "deactivation-impact",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { readItems, readPage, readPageSize, readTotalCount } from "@/api/paginated-response-fields";
|
||||||
|
|
||||||
export interface PaginatedResult<T> {
|
export interface PaginatedResult<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
|
|
@ -41,26 +43,13 @@ export function handlePaginatedResponse<T>(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRecord(data)) {
|
if (isRecord(data)) {
|
||||||
const nested = data.data ?? data.Data ?? data.value ?? data;
|
const items = readItems<T>(data);
|
||||||
const items = Array.isArray(nested) ? (nested as T[]) : [];
|
|
||||||
|
|
||||||
const totalCount =
|
|
||||||
(typeof data.totalCount === "number" ? data.totalCount : undefined) ??
|
|
||||||
(typeof data.TotalCount === "number" ? data.TotalCount : undefined) ??
|
|
||||||
(typeof data.Count === "number" ? data.Count : undefined) ??
|
|
||||||
items.length;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
totalCount,
|
totalCount: readTotalCount(data, items.length),
|
||||||
page:
|
page: readPage(data, page),
|
||||||
(typeof data.pageNumber === "number" ? data.pageNumber : undefined) ??
|
pageSize: readPageSize(data, pageSize),
|
||||||
(typeof data.PageNumber === "number" ? data.PageNumber : undefined) ??
|
|
||||||
page,
|
|
||||||
pageSize:
|
|
||||||
(typeof data.pageSize === "number" ? data.pageSize : undefined) ??
|
|
||||||
(typeof data.PageSize === "number" ? data.PageSize : undefined) ??
|
|
||||||
pageSize,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
36
src/api/paginated-response-fields.ts
Normal file
36
src/api/paginated-response-fields.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
/** Field readers for paginated API payloads that tolerate PascalCase/camelCase and nested envelopes. */
|
||||||
|
|
||||||
|
export function readItems<T>(record: Record<string, unknown>): T[] {
|
||||||
|
const nested = record.data ?? record.Data ?? record.value;
|
||||||
|
const itemsFromPaged =
|
||||||
|
(Array.isArray(record.items) ? (record.items as T[]) : undefined) ??
|
||||||
|
(Array.isArray(record.Items) ? (record.Items as T[]) : undefined);
|
||||||
|
|
||||||
|
return itemsFromPaged ?? (Array.isArray(nested) ? (nested as T[]) : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readTotalCount(record: Record<string, unknown>, fallback: number): number {
|
||||||
|
return (
|
||||||
|
(typeof record.totalCount === "number" ? record.totalCount : undefined) ??
|
||||||
|
(typeof record.TotalCount === "number" ? record.TotalCount : undefined) ??
|
||||||
|
(typeof record.Count === "number" ? record.Count : undefined) ??
|
||||||
|
fallback
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPage(record: Record<string, unknown>, fallback: number): number {
|
||||||
|
return (
|
||||||
|
(typeof record.page === "number" ? record.page : undefined) ??
|
||||||
|
(typeof record.pageNumber === "number" ? record.pageNumber : undefined) ??
|
||||||
|
(typeof record.PageNumber === "number" ? record.PageNumber : undefined) ??
|
||||||
|
fallback
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPageSize(record: Record<string, unknown>, fallback: number): number {
|
||||||
|
return (
|
||||||
|
(typeof record.pageSize === "number" ? record.pageSize : undefined) ??
|
||||||
|
(typeof record.PageSize === "number" ? record.PageSize : undefined) ??
|
||||||
|
fallback
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
||||||
|
import type { VendorPosFilters } from "../_hooks/use-vendor-pos-filters";
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
"",
|
||||||
|
"Sent",
|
||||||
|
"Acknowledged",
|
||||||
|
"In Progress",
|
||||||
|
"Completed",
|
||||||
|
"Verified",
|
||||||
|
"Cancelled",
|
||||||
|
];
|
||||||
|
|
||||||
|
interface VendorPosListControlsProps {
|
||||||
|
filters: VendorPosFilters;
|
||||||
|
vendors: VendorListItem[];
|
||||||
|
totalCount: number;
|
||||||
|
isFetching: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onFilterChange: (name: Exclude<keyof VendorPosFilters, "search">, value: string) => void;
|
||||||
|
onClearFilters: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorPosListControls({
|
||||||
|
filters,
|
||||||
|
vendors,
|
||||||
|
totalCount,
|
||||||
|
isFetching,
|
||||||
|
isLoading,
|
||||||
|
onSearchChange,
|
||||||
|
onFilterChange,
|
||||||
|
onClearFilters,
|
||||||
|
}: VendorPosListControlsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", md: "row" }}
|
||||||
|
spacing={2}
|
||||||
|
sx={{
|
||||||
|
alignItems: { xs: "stretch", md: "center" },
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={2}
|
||||||
|
sx={{
|
||||||
|
alignItems: "baseline",
|
||||||
|
flexShrink: 0,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h5" component="h1" sx={{ flexShrink: 0, whiteSpace: "nowrap" }}>
|
||||||
|
Vendor POs
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ color: "text.secondary", flexShrink: 0, whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
{totalCount} total
|
||||||
|
</Typography>
|
||||||
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(event) => onSearchChange(event.target.value)}
|
||||||
|
placeholder="Search PO #, dispatch, vendor, WO..."
|
||||||
|
sx={{ width: { xs: "100%", sm: 360 }, minWidth: 280, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", md: "row" }}
|
||||||
|
spacing={2}
|
||||||
|
sx={{ flexWrap: "wrap", alignItems: "flex-start" }}
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
size="small"
|
||||||
|
sx={{ width: { xs: "100%", sm: 160 }, minWidth: 140, flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<InputLabel>Status</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Status"
|
||||||
|
value={filters.status}
|
||||||
|
onChange={(event) => onFilterChange("status", event.target.value)}
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((status) => (
|
||||||
|
<MenuItem key={status || "all"} value={status}>
|
||||||
|
{status || "All"}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl
|
||||||
|
size="small"
|
||||||
|
sx={{ width: { xs: "100%", sm: 240 }, minWidth: 180, flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<InputLabel>Vendor</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Vendor"
|
||||||
|
value={filters.vendorId}
|
||||||
|
onChange={(event) => onFilterChange("vendorId", event.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All</MenuItem>
|
||||||
|
{vendors.map((vendor) => (
|
||||||
|
<MenuItem key={String(vendor.id)} value={String(vendor.id)}>
|
||||||
|
{vendor.companyName}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="date"
|
||||||
|
label="From"
|
||||||
|
value={filters.dateFrom}
|
||||||
|
onChange={(event) => onFilterChange("dateFrom", event.target.value)}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
sx={{ width: { xs: "100%", sm: 164 }, minWidth: 140, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="date"
|
||||||
|
label="To"
|
||||||
|
value={filters.dateTo}
|
||||||
|
onChange={(event) => onFilterChange("dateTo", event.target.value)}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
sx={{ width: { xs: "100%", sm: 164 }, minWidth: 140, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
onClick={onClearFilters}
|
||||||
|
sx={{ minWidth: "max-content", flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { VendorPoListItem } from "@/domain/vendor-pos/types/vendor-po";
|
||||||
|
import { toDateInputValue } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
interface VendorPosListTableProps {
|
||||||
|
rows: VendorPoListItem[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onNavigate: (id: string | number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(value: number | null): string {
|
||||||
|
if (value == null) return "—";
|
||||||
|
return `$${value.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
return toDateInputValue(value) || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorPosListTable({ rows, isLoading, onNavigate }: VendorPosListTableProps) {
|
||||||
|
return (
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-semibold">PO #</TableCell>
|
||||||
|
<TableCell className="font-semibold">Dispatch #</TableCell>
|
||||||
|
<TableCell className="font-semibold">Vendor</TableCell>
|
||||||
|
<TableCell className="font-semibold">WO #</TableCell>
|
||||||
|
<TableCell className="font-semibold">Location</TableCell>
|
||||||
|
<TableCell className="font-semibold">NTE</TableCell>
|
||||||
|
<TableCell className="font-semibold">Status</TableCell>
|
||||||
|
<TableCell className="font-semibold">Uplifts</TableCell>
|
||||||
|
<TableCell className="font-semibold">Dispatched</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} align="center" className="py-10">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} align="center" className="py-10">
|
||||||
|
<Typography sx={{ color: "text.secondary" }}>No vendor POs found</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={String(row.id)}
|
||||||
|
hover
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => onNavigate(row.id)}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
className="font-semibold"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onNavigate(row.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.poNumber || "—"}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.dispatchNumber || "—"}</TableCell>
|
||||||
|
<TableCell>{row.vendorName || "—"}</TableCell>
|
||||||
|
<TableCell>{row.primaryWOInternalNumber || "—"}</TableCell>
|
||||||
|
<TableCell>{row.locationName || "—"}</TableCell>
|
||||||
|
<TableCell>{formatMoney(row.nteAmount)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={row.status || "—"} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.pendingUpliftCount > 0 ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
color="warning"
|
||||||
|
label={`${row.pendingUpliftCount} pending`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(row.dispatchedAt)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { VendorPoListParams } from "@/domain/vendor-pos/types/vendor-po";
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
|
||||||
|
export interface VendorPosFilters {
|
||||||
|
search: string;
|
||||||
|
status: string;
|
||||||
|
vendorId: string;
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type VendorPosFilterName = Exclude<keyof VendorPosFilters, "search">;
|
||||||
|
|
||||||
|
const INITIAL_FILTERS: VendorPosFilters = {
|
||||||
|
search: "",
|
||||||
|
status: "",
|
||||||
|
vendorId: "",
|
||||||
|
dateFrom: "",
|
||||||
|
dateTo: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useVendorPosFilters() {
|
||||||
|
const [filters, setFilters] = useState<VendorPosFilters>(INITIAL_FILTERS);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [pageSize, setPageSize] = useState(12);
|
||||||
|
const debouncedSearch = useDebounce(filters.search, 300);
|
||||||
|
|
||||||
|
const listParams = useMemo<VendorPoListParams>(
|
||||||
|
() => ({
|
||||||
|
search: debouncedSearch,
|
||||||
|
page: page + 1,
|
||||||
|
pageSize,
|
||||||
|
status: filters.status,
|
||||||
|
vendorId: filters.vendorId,
|
||||||
|
dateFrom: filters.dateFrom,
|
||||||
|
dateTo: filters.dateTo,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
debouncedSearch,
|
||||||
|
filters.dateFrom,
|
||||||
|
filters.dateTo,
|
||||||
|
filters.status,
|
||||||
|
filters.vendorId,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setSearch = (search: string) => {
|
||||||
|
setFilters((current) => ({ ...current, search }));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setFilter = (name: VendorPosFilterName, value: string) => {
|
||||||
|
setFilters((current) => ({ ...current, [name]: value }));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setFilters((current) => ({
|
||||||
|
...current,
|
||||||
|
status: "",
|
||||||
|
vendorId: "",
|
||||||
|
dateFrom: "",
|
||||||
|
dateTo: "",
|
||||||
|
}));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePageSize = (nextPageSize: number) => {
|
||||||
|
setPageSize(nextPageSize);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
filters,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
listParams,
|
||||||
|
setSearch,
|
||||||
|
setFilter,
|
||||||
|
clearFilters,
|
||||||
|
setPage,
|
||||||
|
changePageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,300 +1,59 @@
|
||||||
import { useMemo, useState } from "react";
|
import { Alert, Box, TablePagination } from "@mui/material";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import {
|
import { VendorPosListControls } from "./_components/vendor-pos-list-controls";
|
||||||
Alert,
|
import { VendorPosListTable } from "./_components/vendor-pos-list-table";
|
||||||
Box,
|
import { useVendorPosFilters } from "./_hooks/use-vendor-pos-filters";
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
CircularProgress,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Paper,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TablePagination,
|
|
||||||
TableRow,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { useVendorPosList } from "@/domain/vendor-pos/use-cases/use-vendor-pos-list";
|
import { useVendorPosList } from "@/domain/vendor-pos/use-cases/use-vendor-pos-list";
|
||||||
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
|
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
|
||||||
import { useDebounce } from "@/hooks/use-debounce";
|
|
||||||
import { toDateInputValue } from "@/lib/time-utils";
|
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
|
||||||
"",
|
|
||||||
"Sent",
|
|
||||||
"Acknowledged",
|
|
||||||
"In Progress",
|
|
||||||
"Completed",
|
|
||||||
"Verified",
|
|
||||||
"Cancelled",
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatMoney(value: number | null): string {
|
|
||||||
if (value == null) return "—";
|
|
||||||
return `$${value.toFixed(2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(value: string): string {
|
|
||||||
if (!value) return "—";
|
|
||||||
return toDateInputValue(value) || "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VendorPosListPage() {
|
export default function VendorPosListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState("");
|
const {
|
||||||
const [page, setPage] = useState(0);
|
filters,
|
||||||
const [pageSize, setPageSize] = useState(12);
|
page,
|
||||||
const [status, setStatus] = useState("");
|
pageSize,
|
||||||
const [vendorId, setVendorId] = useState("");
|
listParams,
|
||||||
const [dateFrom, setDateFrom] = useState("");
|
setSearch,
|
||||||
const [dateTo, setDateTo] = useState("");
|
setFilter,
|
||||||
|
clearFilters,
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
setPage,
|
||||||
|
changePageSize,
|
||||||
const listParams = useMemo(
|
} = useVendorPosFilters();
|
||||||
() => ({
|
|
||||||
search: debouncedSearch,
|
|
||||||
page: page + 1,
|
|
||||||
pageSize,
|
|
||||||
status,
|
|
||||||
vendorId,
|
|
||||||
dateFrom,
|
|
||||||
dateTo,
|
|
||||||
}),
|
|
||||||
[debouncedSearch, page, pageSize, status, vendorId, dateFrom, dateTo],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, isLoading, isFetching, error } = useVendorPosList(listParams);
|
const { data, isLoading, isFetching, error } = useVendorPosList(listParams);
|
||||||
const { data: vendorsData } = useVendorsList({ page: 1, pageSize: 500 });
|
const { data: vendorsData } = useVendorsList({ page: 1, pageSize: 500 });
|
||||||
|
|
||||||
const rows = data?.items ?? [];
|
const rows = data?.items ?? [];
|
||||||
const totalCount = data?.totalCount ?? 0;
|
const totalCount = data?.totalCount ?? 0;
|
||||||
const vendors = vendorsData?.items ?? [];
|
const vendors = vendorsData?.items ?? [];
|
||||||
|
|
||||||
const clearFilters = () => {
|
|
||||||
setStatus("");
|
|
||||||
setVendorId("");
|
|
||||||
setDateFrom("");
|
|
||||||
setDateTo("");
|
|
||||||
setPage(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-4 p-4">
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
<Stack
|
<VendorPosListControls
|
||||||
direction={{ xs: "column", md: "row" }}
|
filters={filters}
|
||||||
spacing={2}
|
vendors={vendors}
|
||||||
sx={{
|
totalCount={totalCount}
|
||||||
alignItems: { xs: "stretch", md: "center" },
|
isFetching={isFetching}
|
||||||
justifyContent: "space-between",
|
isLoading={isLoading}
|
||||||
}}
|
onSearchChange={setSearch}
|
||||||
>
|
onFilterChange={setFilter}
|
||||||
<Stack
|
onClearFilters={clearFilters}
|
||||||
direction="row"
|
/>
|
||||||
spacing={2}
|
|
||||||
sx={{
|
|
||||||
alignItems: "baseline",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h5" component="h1">
|
|
||||||
Vendor POs
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{
|
|
||||||
color: "text.secondary",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{totalCount} total
|
|
||||||
</Typography>
|
|
||||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearch(e.target.value);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
placeholder="Search PO #, dispatch, vendor, WO..."
|
|
||||||
className="min-w-[280px]"
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Stack
|
|
||||||
direction={{ xs: "column", md: "row" }}
|
|
||||||
spacing={2}
|
|
||||||
sx={{
|
|
||||||
flexWrap: "wrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FormControl size="small" className="min-w-[140px]">
|
|
||||||
<InputLabel>Status</InputLabel>
|
|
||||||
<Select
|
|
||||||
label="Status"
|
|
||||||
value={status}
|
|
||||||
onChange={(e) => {
|
|
||||||
setStatus(e.target.value);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{STATUS_OPTIONS.map((s) => (
|
|
||||||
<MenuItem key={s || "all"} value={s}>
|
|
||||||
{s || "All"}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControl size="small" className="min-w-[180px]">
|
|
||||||
<InputLabel>Vendor</InputLabel>
|
|
||||||
<Select
|
|
||||||
label="Vendor"
|
|
||||||
value={vendorId}
|
|
||||||
onChange={(e) => {
|
|
||||||
setVendorId(e.target.value);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem value="">All</MenuItem>
|
|
||||||
{vendors.map((v) => (
|
|
||||||
<MenuItem key={String(v.id)} value={String(v.id)}>
|
|
||||||
{v.companyName}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
type="date"
|
|
||||||
label="From"
|
|
||||||
value={dateFrom}
|
|
||||||
onChange={(e) => {
|
|
||||||
setDateFrom(e.target.value);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
type="date"
|
|
||||||
label="To"
|
|
||||||
value={dateTo}
|
|
||||||
onChange={(e) => {
|
|
||||||
setDateTo(e.target.value);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
|
||||||
/>
|
|
||||||
<Button variant="text" onClick={clearFilters}>
|
|
||||||
Clear filters
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
{Boolean(error) && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load vendor POs"}
|
{error instanceof Error ? error.message : "Failed to load vendor POs"}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<TableContainer component={Paper} variant="outlined">
|
<VendorPosListTable
|
||||||
<Table size="small">
|
rows={rows}
|
||||||
<TableHead>
|
isLoading={isLoading}
|
||||||
<TableRow>
|
onNavigate={(id) => navigate(`/vendor-pos/${id}`)}
|
||||||
<TableCell className="font-semibold">PO #</TableCell>
|
/>
|
||||||
<TableCell className="font-semibold">Dispatch #</TableCell>
|
|
||||||
<TableCell className="font-semibold">Vendor</TableCell>
|
|
||||||
<TableCell className="font-semibold">WO #</TableCell>
|
|
||||||
<TableCell className="font-semibold">Location</TableCell>
|
|
||||||
<TableCell className="font-semibold">NTE</TableCell>
|
|
||||||
<TableCell className="font-semibold">Status</TableCell>
|
|
||||||
<TableCell className="font-semibold">Uplifts</TableCell>
|
|
||||||
<TableCell className="font-semibold">Dispatched</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{isLoading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={9} align="center" className="py-10">
|
|
||||||
<CircularProgress size={28} />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : rows.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={9} align="center" className="py-10">
|
|
||||||
<Typography
|
|
||||||
sx={{
|
|
||||||
color: "text.secondary",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No vendor POs found
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
rows.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={String(row.id)}
|
|
||||||
hover
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => navigate(`/vendor-pos/${row.id}`)}
|
|
||||||
>
|
|
||||||
<TableCell>
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
className="font-semibold"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate(`/vendor-pos/${row.id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{row.poNumber || "—"}
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{row.dispatchNumber || "—"}</TableCell>
|
|
||||||
<TableCell>{row.vendorName || "—"}</TableCell>
|
|
||||||
<TableCell>{row.primaryWOInternalNumber || "—"}</TableCell>
|
|
||||||
<TableCell>{row.locationName || "—"}</TableCell>
|
|
||||||
<TableCell>{formatMoney(row.nteAmount)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Chip size="small" label={row.status || "—"} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{row.pendingUpliftCount > 0 ? (
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
color="warning"
|
|
||||||
label={`${row.pendingUpliftCount} pending`}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{formatDate(row.dispatchedAt)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
<TablePagination
|
<TablePagination
|
||||||
component="div"
|
component="div"
|
||||||
count={totalCount}
|
count={totalCount}
|
||||||
page={page}
|
page={page}
|
||||||
onPageChange={(_e, nextPage) => setPage(nextPage)}
|
onPageChange={(_event, nextPage) => setPage(nextPage)}
|
||||||
rowsPerPage={pageSize}
|
rowsPerPage={pageSize}
|
||||||
onRowsPerPageChange={(e) => {
|
onRowsPerPageChange={(event) => changePageSize(Number(event.target.value))}
|
||||||
setPageSize(Number(e.target.value));
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
rowsPerPageOptions={[12, 24, 48]}
|
rowsPerPageOptions={[12, 24, 48]}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
166
src/app/(protected)/vendors/_components/use-vendor-detail-drawer.ts
vendored
Normal file
166
src/app/(protected)/vendors/_components/use-vendor-detail-drawer.ts
vendored
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useForm, useWatch, type FieldErrors, type Resolver } from "react-hook-form";
|
||||||
|
import type { ZodError } from "zod";
|
||||||
|
import {
|
||||||
|
getChangedVendorValues,
|
||||||
|
vendorUpdateSchema,
|
||||||
|
type VendorFormValues,
|
||||||
|
} from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
|
||||||
|
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
||||||
|
import type { Vendor, VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
const DEFAULT_VALUES: VendorFormValues = {
|
||||||
|
companyName: "",
|
||||||
|
contactName: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
companyPhone: "",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
totalJobs: 0,
|
||||||
|
address: "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
zip: "",
|
||||||
|
tradeSpecialties: "",
|
||||||
|
isActive: true,
|
||||||
|
companyId: null,
|
||||||
|
preferredContact: "Phone",
|
||||||
|
};
|
||||||
|
|
||||||
|
function toFormValues(vendor: Vendor): VendorFormValues {
|
||||||
|
return {
|
||||||
|
companyName: vendor.companyName ?? "",
|
||||||
|
contactName: vendor.contactName ?? "",
|
||||||
|
email: vendor.email ?? "",
|
||||||
|
phone: vendor.phone ?? "",
|
||||||
|
companyPhone: vendor.companyPhone ?? "",
|
||||||
|
googleMapsUrl: vendor.googleMapsUrl ?? "",
|
||||||
|
notes: vendor.notes ?? "",
|
||||||
|
totalJobs: vendor.totalJobs ?? 0,
|
||||||
|
address: vendor.address ?? "",
|
||||||
|
city: vendor.city ?? "",
|
||||||
|
state: vendor.state ?? "",
|
||||||
|
zip: vendor.zip ?? "",
|
||||||
|
tradeSpecialties: vendor.tradeSpecialties ?? "",
|
||||||
|
isActive: vendor.isActive,
|
||||||
|
companyId: vendor.companyId ?? null,
|
||||||
|
preferredContact: vendor.preferredContact ?? "Phone",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFieldErrors(error: ZodError): FieldErrors<VendorFormValues> {
|
||||||
|
return error.issues.reduce<FieldErrors<VendorFormValues>>((fieldErrors, issue) => {
|
||||||
|
const field = issue.path[0];
|
||||||
|
if (typeof field === "string") {
|
||||||
|
fieldErrors[field as keyof VendorFormValues] = {
|
||||||
|
type: issue.code,
|
||||||
|
message: issue.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return fieldErrors;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseVendorDrawerFormProps {
|
||||||
|
detail: Vendor | undefined;
|
||||||
|
open: boolean;
|
||||||
|
vendorId: string | number | undefined;
|
||||||
|
onModeChange: (mode: "view" | "edit") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVendorDetailDrawerForm({
|
||||||
|
detail,
|
||||||
|
open,
|
||||||
|
vendorId,
|
||||||
|
onModeChange,
|
||||||
|
}: UseVendorDrawerFormProps) {
|
||||||
|
const { data: facets } = useVendorFacets();
|
||||||
|
const updateVendor = useUpdateVendor();
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [original, setOriginal] = useState<VendorFormValues | null>(null);
|
||||||
|
const originalRef = useRef<VendorFormValues | null>(null);
|
||||||
|
const companies = useMemo(() => facets?.companies ?? [], [facets]);
|
||||||
|
|
||||||
|
const resolver = useCallback<Resolver<VendorFormValues>>(async (values) => {
|
||||||
|
const result = vendorUpdateSchema.safeParse(
|
||||||
|
getChangedVendorValues(values, originalRef.current),
|
||||||
|
);
|
||||||
|
return result.success
|
||||||
|
? { values, errors: {} }
|
||||||
|
: { values: {}, errors: buildFieldErrors(result.error) };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const form = useForm<VendorFormValues>({
|
||||||
|
resolver,
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: DEFAULT_VALUES,
|
||||||
|
});
|
||||||
|
const { control, reset, setValue, trigger } = form;
|
||||||
|
const currentValues = useWatch({ control });
|
||||||
|
const changedValues = useMemo(
|
||||||
|
() => getChangedVendorValues(currentValues, original),
|
||||||
|
[currentValues, original],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && detail) {
|
||||||
|
const values = toFormValues(detail);
|
||||||
|
reset(values);
|
||||||
|
setOriginal(values);
|
||||||
|
originalRef.current = values;
|
||||||
|
setSubmitError(null);
|
||||||
|
} else if (!open) {
|
||||||
|
setOriginal(null);
|
||||||
|
originalRef.current = null;
|
||||||
|
setSubmitError(null);
|
||||||
|
}
|
||||||
|
}, [open, detail, reset]);
|
||||||
|
|
||||||
|
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
||||||
|
if (!company) return;
|
||||||
|
const options = { shouldDirty: true, shouldValidate: true } as const;
|
||||||
|
setValue("companyId", company.companyId, options);
|
||||||
|
setValue("companyPhone", company.companyPhone, options);
|
||||||
|
setValue("email", company.email, options);
|
||||||
|
setValue("address", company.address, options);
|
||||||
|
setValue("city", company.city, options);
|
||||||
|
setValue("state", company.state, options);
|
||||||
|
setValue("zip", company.zip, options);
|
||||||
|
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
||||||
|
void trigger();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
if (original) reset(original);
|
||||||
|
setSubmitError(null);
|
||||||
|
onModeChange("view");
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = (values: VendorFormValues) => {
|
||||||
|
if (vendorId === undefined || vendorId === "") return;
|
||||||
|
const changed = getChangedVendorValues(values, original);
|
||||||
|
if (Object.keys(changed).length === 0) return;
|
||||||
|
setSubmitError(null);
|
||||||
|
updateVendor.mutate(
|
||||||
|
{ id: vendorId, values: changed },
|
||||||
|
{
|
||||||
|
onSuccess: () => onModeChange("view"),
|
||||||
|
onError: (error: Error) => setSubmitError(error.message || "Failed to save vendor"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...form,
|
||||||
|
cancel,
|
||||||
|
companies,
|
||||||
|
hasChanges: Object.keys(changedValues).length > 0,
|
||||||
|
isFormValid: vendorUpdateSchema.safeParse(changedValues).success,
|
||||||
|
onCompanySelected,
|
||||||
|
submit,
|
||||||
|
submitError,
|
||||||
|
updateVendor,
|
||||||
|
};
|
||||||
|
}
|
||||||
338
src/app/(protected)/vendors/_components/vendor-detail-drawer-content.tsx
vendored
Normal file
338
src/app/(protected)/vendors/_components/vendor-detail-drawer-content.tsx
vendored
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
import type { FormEventHandler, ReactNode } from "react";
|
||||||
|
import { Controller, type Control, type FieldErrors } from "react-hook-form";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
|
import LaunchIcon from "@mui/icons-material/Launch";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
FormControlLabel,
|
||||||
|
IconButton,
|
||||||
|
Link,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { VendorFormFields } from "./vendor-form-fields";
|
||||||
|
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
import type {
|
||||||
|
Vendor,
|
||||||
|
VendorFacetCompany,
|
||||||
|
VendorListItem,
|
||||||
|
VendorPreferredContact,
|
||||||
|
} from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
const PREFERRED_CONTACT_LABELS: Record<VendorPreferredContact, string> = {
|
||||||
|
Phone: "Phone",
|
||||||
|
Email: "Email",
|
||||||
|
Text: "Text Message",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getInitials(name: string): string {
|
||||||
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 0) return "—";
|
||||||
|
const first = parts[0]?.[0] ?? "";
|
||||||
|
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : "";
|
||||||
|
return (first + last).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
vendor: VendorListItem | null;
|
||||||
|
mode: "view" | "edit";
|
||||||
|
pending: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorDrawerHeader({ vendor, mode, pending, onClose }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
sx={{
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
px: 3,
|
||||||
|
py: 2,
|
||||||
|
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||||
|
<Box
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
sx={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "var(--accent, #1976d2)",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getInitials(vendor?.contactName ?? "")}
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
id="vendor-detail-drawer-title"
|
||||||
|
variant="subtitle1"
|
||||||
|
component="h2"
|
||||||
|
className="font-semibold"
|
||||||
|
>
|
||||||
|
{vendor?.companyName || "Vendor"}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
{mode === "edit" ? "Editing vendor" : "Vendor details"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
<IconButton aria-label="Close drawer" onClick={onClose} size="small" disabled={pending}>
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ViewProps {
|
||||||
|
detail: Vendor;
|
||||||
|
mapsUrl: string | undefined;
|
||||||
|
submitError: string | null;
|
||||||
|
trades: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorDrawerView({ detail, mapsUrl, submitError, trades }: ViewProps) {
|
||||||
|
return (
|
||||||
|
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
|
||||||
|
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={detail.isActive ? "Active" : "Inactive"}
|
||||||
|
color={detail.isActive ? "success" : "default"}
|
||||||
|
/>
|
||||||
|
{Boolean(trades[0]) && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
label={trades.length > 1 ? `${trades[0]} +${trades.length - 1}` : trades[0]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<DetailSection title="Technician">
|
||||||
|
<DetailField label="Technician Name" value={detail.contactName} />
|
||||||
|
<DetailField label="Technician Phone" value={detail.phone} />
|
||||||
|
<DetailField
|
||||||
|
label="Preferred Contact"
|
||||||
|
value={PREFERRED_CONTACT_LABELS[detail.preferredContact ?? "Phone"]}
|
||||||
|
/>
|
||||||
|
</DetailSection>
|
||||||
|
<DetailSection title="Company">
|
||||||
|
<DetailField label="Company" value={detail.companyName} />
|
||||||
|
<DetailField label="Company Phone" value={detail.companyPhone} />
|
||||||
|
<DetailField label="Email" value={detail.email} />
|
||||||
|
</DetailSection>
|
||||||
|
<DetailSection title="Location">
|
||||||
|
<DetailField
|
||||||
|
label="Address"
|
||||||
|
value={[detail.address, detail.city, detail.state, detail.zip].filter(Boolean).join(", ")}
|
||||||
|
/>
|
||||||
|
{Boolean(mapsUrl) && (
|
||||||
|
<Link
|
||||||
|
href={mapsUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<LaunchIcon fontSize="small" />
|
||||||
|
Open in Google Maps
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</DetailSection>
|
||||||
|
<TradesSection trades={trades} />
|
||||||
|
<DetailSection title="Activity">
|
||||||
|
<DetailField label="Total Jobs" value={String(detail.totalJobs ?? 0)} />
|
||||||
|
</DetailSection>
|
||||||
|
{Boolean(detail.notes) && (
|
||||||
|
<DetailSection title="Notes">
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{detail.notes}
|
||||||
|
</Typography>
|
||||||
|
</DetailSection>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TradesSection({ trades }: { trades: string[] }) {
|
||||||
|
return (
|
||||||
|
<DetailSection title="Trades">
|
||||||
|
{trades.length === 0 ? (
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
No trades specified.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box className="flex flex-wrap gap-1">
|
||||||
|
{trades.map((trade, index) => (
|
||||||
|
<Chip
|
||||||
|
key={`${trade}-${index}`}
|
||||||
|
size="small"
|
||||||
|
label={index === 0 ? `${trade} (primary)` : trade}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DetailSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditProps {
|
||||||
|
control: Control<VendorFormValues>;
|
||||||
|
errors: FieldErrors<VendorFormValues>;
|
||||||
|
companies: VendorFacetCompany[];
|
||||||
|
tradeOptions: string[];
|
||||||
|
submitError: string | null;
|
||||||
|
onCompanySelected: (company: VendorFacetCompany | null) => void;
|
||||||
|
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorDrawerEdit({
|
||||||
|
control,
|
||||||
|
errors,
|
||||||
|
companies,
|
||||||
|
tradeOptions,
|
||||||
|
submitError,
|
||||||
|
onCompanySelected,
|
||||||
|
onSubmit,
|
||||||
|
}: EditProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
id="vendor-edit-form"
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="flex flex-1 flex-col overflow-y-auto"
|
||||||
|
sx={{ px: 3, py: 3 }}
|
||||||
|
>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="isActive"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch checked={Boolean(field.value)} onChange={field.onChange} color="success" />
|
||||||
|
}
|
||||||
|
label={field.value ? "Active" : "Inactive"}
|
||||||
|
sx={{ alignSelf: "flex-start" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<VendorFormFields
|
||||||
|
control={control}
|
||||||
|
errors={errors}
|
||||||
|
companies={companies}
|
||||||
|
tradeOptions={tradeOptions}
|
||||||
|
onCompanySelected={onCompanySelected}
|
||||||
|
showTotalJobs
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActionsProps {
|
||||||
|
mode: "view" | "edit";
|
||||||
|
mapsUrl: string | undefined;
|
||||||
|
pending: boolean;
|
||||||
|
canSave: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorDrawerActions({
|
||||||
|
mode,
|
||||||
|
mapsUrl,
|
||||||
|
pending,
|
||||||
|
canSave,
|
||||||
|
onCancel,
|
||||||
|
onEdit,
|
||||||
|
}: ActionsProps) {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={2}
|
||||||
|
sx={{
|
||||||
|
px: 3,
|
||||||
|
py: 2,
|
||||||
|
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode === "view" ? (
|
||||||
|
<>
|
||||||
|
{Boolean(mapsUrl) && (
|
||||||
|
<Tooltip title="Open in Google Maps">
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
href={mapsUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<LaunchIcon />}
|
||||||
|
>
|
||||||
|
Maps
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Box className="flex-1" />
|
||||||
|
<Button variant="contained" onClick={onEdit} startIcon={<EditOutlinedIcon />}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="outlined" onClick={onCancel} disabled={pending} fullWidth>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
form="vendor-edit-form"
|
||||||
|
disabled={pending || !canSave}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{pending ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
className="font-semibold uppercase"
|
||||||
|
sx={{ color: "text.secondary", mb: 1 }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1.5}>{children}</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailField({ label, value }: { label: string; value: string }) {
|
||||||
|
const display = value && value.trim() !== "" ? value : "—";
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">{display}</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,47 +1,16 @@
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { Alert, Box, CircularProgress, Drawer, Stack, Typography } from "@mui/material";
|
||||||
import { useForm, useWatch, Controller } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
|
||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
|
||||||
import LaunchIcon from "@mui/icons-material/Launch";
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
VendorDrawerActions,
|
||||||
Box,
|
VendorDrawerEdit,
|
||||||
Button,
|
VendorDrawerHeader,
|
||||||
Chip,
|
VendorDrawerView,
|
||||||
CircularProgress,
|
} from "./vendor-detail-drawer-content";
|
||||||
Drawer,
|
import { useVendorDetailDrawerForm } from "./use-vendor-detail-drawer";
|
||||||
FormControlLabel,
|
|
||||||
IconButton,
|
|
||||||
Link,
|
|
||||||
Stack,
|
|
||||||
Switch,
|
|
||||||
Tooltip,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { VendorFormFields } from "./vendor-form-fields";
|
|
||||||
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
|
||||||
import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
|
|
||||||
import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail";
|
import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail";
|
||||||
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
||||||
import type { Vendor, VendorListItem, VendorPreferredContact } from "@/domain/vendors/types/vendor";
|
|
||||||
|
|
||||||
const DRAWER_WIDTH = 420;
|
const DRAWER_WIDTH = 420;
|
||||||
|
|
||||||
const PREFERRED_CONTACT_LABELS: Record<VendorPreferredContact, string> = {
|
|
||||||
Phone: "Phone",
|
|
||||||
Email: "Email",
|
|
||||||
Text: "Text Message",
|
|
||||||
};
|
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
|
||||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
||||||
if (parts.length === 0) return "—";
|
|
||||||
const first = parts[0]?.[0] ?? "";
|
|
||||||
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : "";
|
|
||||||
return (first + last).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitTrades(value: string | undefined): string[] {
|
function splitTrades(value: string | undefined): string[] {
|
||||||
return (value ?? "")
|
return (value ?? "")
|
||||||
.split(",")
|
.split(",")
|
||||||
|
|
@ -49,27 +18,6 @@ function splitTrades(value: string | undefined): string[] {
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toFormValues(vendor: Vendor): VendorFormValues {
|
|
||||||
return {
|
|
||||||
companyName: vendor.companyName ?? "",
|
|
||||||
contactName: vendor.contactName ?? "",
|
|
||||||
email: vendor.email ?? "",
|
|
||||||
phone: vendor.phone ?? "",
|
|
||||||
companyPhone: vendor.companyPhone ?? "",
|
|
||||||
googleMapsUrl: vendor.googleMapsUrl ?? "",
|
|
||||||
notes: vendor.notes ?? "",
|
|
||||||
totalJobs: vendor.totalJobs ?? 0,
|
|
||||||
address: vendor.address ?? "",
|
|
||||||
city: vendor.city ?? "",
|
|
||||||
state: vendor.state ?? "",
|
|
||||||
zip: vendor.zip ?? "",
|
|
||||||
tradeSpecialties: vendor.tradeSpecialties ?? "",
|
|
||||||
isActive: vendor.isActive,
|
|
||||||
companyId: vendor.companyId ?? null,
|
|
||||||
preferredContact: vendor.preferredContact ?? "Phone",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VendorDetailDrawerProps {
|
interface VendorDetailDrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
vendor: VendorListItem | null;
|
vendor: VendorListItem | null;
|
||||||
|
|
@ -88,109 +36,20 @@ export function VendorDetailDrawer({
|
||||||
tradeOptions,
|
tradeOptions,
|
||||||
}: VendorDetailDrawerProps) {
|
}: VendorDetailDrawerProps) {
|
||||||
const vendorId = vendor?.id;
|
const vendorId = vendor?.id;
|
||||||
const { data: facets } = useVendorFacets();
|
|
||||||
const {
|
const {
|
||||||
data: detail,
|
data: detail,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
} = useVendorDetail(vendorId !== undefined && vendorId !== "" ? String(vendorId) : undefined);
|
} = useVendorDetail(vendorId !== undefined && vendorId !== "" ? String(vendorId) : undefined);
|
||||||
const updateVendor = useUpdateVendor();
|
const drawerForm = useVendorDetailDrawerForm({ detail, open, vendorId, onModeChange });
|
||||||
|
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
||||||
const [original, setOriginal] = useState<VendorFormValues | null>(null);
|
|
||||||
|
|
||||||
const companies = useMemo(() => facets?.companies ?? [], [facets]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
setValue,
|
|
||||||
trigger,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<VendorFormValues>({
|
|
||||||
resolver: zodResolver(vendorSchema),
|
|
||||||
mode: "onChange",
|
|
||||||
defaultValues: {
|
|
||||||
companyName: "",
|
|
||||||
contactName: "",
|
|
||||||
email: "",
|
|
||||||
phone: "",
|
|
||||||
companyPhone: "",
|
|
||||||
googleMapsUrl: "",
|
|
||||||
notes: "",
|
|
||||||
totalJobs: 0,
|
|
||||||
address: "",
|
|
||||||
city: "",
|
|
||||||
state: "",
|
|
||||||
zip: "",
|
|
||||||
tradeSpecialties: "",
|
|
||||||
isActive: true,
|
|
||||||
companyId: null,
|
|
||||||
preferredContact: "Phone",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const currentValues = useWatch({ control });
|
|
||||||
const isFormValid = vendorSchema.safeParse(currentValues).success;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && detail) {
|
|
||||||
const values = toFormValues(detail);
|
|
||||||
reset(values);
|
|
||||||
setOriginal(values);
|
|
||||||
setSubmitError(null);
|
|
||||||
}
|
|
||||||
if (!open) {
|
|
||||||
setOriginal(null);
|
|
||||||
setSubmitError(null);
|
|
||||||
}
|
|
||||||
}, [open, detail, reset]);
|
|
||||||
|
|
||||||
const onCompanySelected = (company: (typeof companies)[number] | null) => {
|
|
||||||
if (!company) return;
|
|
||||||
const options = { shouldDirty: true, shouldValidate: true } as const;
|
|
||||||
setValue("companyId", company.companyId, options);
|
|
||||||
setValue("companyPhone", company.companyPhone, options);
|
|
||||||
setValue("email", company.email, options);
|
|
||||||
setValue("address", company.address, options);
|
|
||||||
setValue("city", company.city, options);
|
|
||||||
setValue("state", company.state, options);
|
|
||||||
setValue("zip", company.zip, options);
|
|
||||||
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
|
||||||
void trigger();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
if (original) {
|
|
||||||
reset(original);
|
|
||||||
}
|
|
||||||
setSubmitError(null);
|
|
||||||
onModeChange("view");
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = (values: VendorFormValues) => {
|
|
||||||
if (vendorId === undefined || vendorId === null || vendorId === "") return;
|
|
||||||
setSubmitError(null);
|
|
||||||
updateVendor.mutate(
|
|
||||||
{ id: vendorId, values },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
onModeChange("view");
|
|
||||||
},
|
|
||||||
onError: (err: Error) => {
|
|
||||||
setSubmitError(err.message || "Failed to save vendor");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const trades = splitTrades(detail?.tradeSpecialties ?? vendor?.tradeSpecialties);
|
const trades = splitTrades(detail?.tradeSpecialties ?? vendor?.tradeSpecialties);
|
||||||
const mapsUrl = detail?.googleMapsUrl;
|
const mapsUrl = detail?.googleMapsUrl;
|
||||||
|
const pending = drawerForm.updateVendor.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
open={open}
|
open={open}
|
||||||
onClose={updateVendor.isPending ? undefined : onClose}
|
onClose={pending ? undefined : onClose}
|
||||||
anchor="right"
|
anchor="right"
|
||||||
aria-labelledby="vendor-detail-drawer-title"
|
aria-labelledby="vendor-detail-drawer-title"
|
||||||
ModalProps={{ keepMounted: false }}
|
ModalProps={{ keepMounted: false }}
|
||||||
|
|
@ -202,56 +61,7 @@ export function VendorDetailDrawer({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack className="flex h-full flex-col">
|
<Stack className="flex h-full flex-col">
|
||||||
<Stack
|
<VendorDrawerHeader vendor={vendor} mode={mode} pending={pending} onClose={onClose} />
|
||||||
direction="row"
|
|
||||||
sx={{
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
px: 3,
|
|
||||||
py: 2,
|
|
||||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
|
||||||
<Box
|
|
||||||
className="flex items-center justify-center"
|
|
||||||
sx={{
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
borderRadius: "50%",
|
|
||||||
backgroundColor: "var(--accent, #1976d2)",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getInitials(vendor?.contactName ?? "")}
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography
|
|
||||||
id="vendor-detail-drawer-title"
|
|
||||||
variant="subtitle1"
|
|
||||||
component="h2"
|
|
||||||
className="font-semibold"
|
|
||||||
>
|
|
||||||
{vendor?.companyName || "Vendor"}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
||||||
{mode === "edit" ? "Editing vendor" : "Vendor details"}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
<IconButton
|
|
||||||
aria-label="Close drawer"
|
|
||||||
onClick={onClose}
|
|
||||||
size="small"
|
|
||||||
disabled={updateVendor.isPending}
|
|
||||||
>
|
|
||||||
<CloseIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Box className="flex flex-1 items-center justify-center">
|
<Box className="flex flex-1 items-center justify-center">
|
||||||
<CircularProgress size={28} />
|
<CircularProgress size={28} />
|
||||||
|
|
@ -263,188 +73,31 @@ export function VendorDetailDrawer({
|
||||||
) : detail ? (
|
) : detail ? (
|
||||||
<>
|
<>
|
||||||
{mode === "view" ? (
|
{mode === "view" ? (
|
||||||
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
|
<VendorDrawerView
|
||||||
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
|
detail={detail}
|
||||||
|
mapsUrl={mapsUrl}
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
submitError={drawerForm.submitError}
|
||||||
<Chip
|
trades={trades}
|
||||||
size="small"
|
/>
|
||||||
label={detail.isActive ? "Active" : "Inactive"}
|
|
||||||
color={detail.isActive ? "success" : "default"}
|
|
||||||
/>
|
|
||||||
{Boolean(trades[0]) && (
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
label={trades.length > 1 ? `${trades[0]} +${trades.length - 1}` : trades[0]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<DetailSection title="Technician">
|
|
||||||
<DetailField label="Technician Name" value={detail.contactName} />
|
|
||||||
<DetailField label="Technician Phone" value={detail.phone} />
|
|
||||||
<DetailField
|
|
||||||
label="Preferred Contact"
|
|
||||||
value={PREFERRED_CONTACT_LABELS[detail.preferredContact ?? "Phone"]}
|
|
||||||
/>
|
|
||||||
</DetailSection>
|
|
||||||
|
|
||||||
<DetailSection title="Company">
|
|
||||||
<DetailField label="Company" value={detail.companyName} />
|
|
||||||
<DetailField label="Company Phone" value={detail.companyPhone} />
|
|
||||||
<DetailField label="Email" value={detail.email} />
|
|
||||||
</DetailSection>
|
|
||||||
|
|
||||||
<DetailSection title="Location">
|
|
||||||
<DetailField
|
|
||||||
label="Address"
|
|
||||||
value={[detail.address, detail.city, detail.state, detail.zip]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ")}
|
|
||||||
/>
|
|
||||||
{Boolean(mapsUrl) && (
|
|
||||||
<Link
|
|
||||||
href={mapsUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<LaunchIcon fontSize="small" />
|
|
||||||
Open in Google Maps
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</DetailSection>
|
|
||||||
|
|
||||||
<DetailSection title="Trades">
|
|
||||||
{trades.length === 0 ? (
|
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
||||||
No trades specified.
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<Box className="flex flex-wrap gap-1">
|
|
||||||
{trades.map((trade, idx) => (
|
|
||||||
<Chip
|
|
||||||
key={`${trade}-${idx}`}
|
|
||||||
size="small"
|
|
||||||
label={idx === 0 ? `${trade} (primary)` : trade}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</DetailSection>
|
|
||||||
|
|
||||||
<DetailSection title="Activity">
|
|
||||||
<DetailField label="Total Jobs" value={String(detail.totalJobs ?? 0)} />
|
|
||||||
</DetailSection>
|
|
||||||
|
|
||||||
{Boolean(detail.notes) && (
|
|
||||||
<DetailSection title="Notes">
|
|
||||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
|
|
||||||
{detail.notes}
|
|
||||||
</Typography>
|
|
||||||
</DetailSection>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
) : (
|
) : (
|
||||||
<Box
|
<VendorDrawerEdit
|
||||||
component="form"
|
control={drawerForm.control}
|
||||||
id="vendor-edit-form"
|
errors={drawerForm.formState.errors}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
companies={drawerForm.companies}
|
||||||
className="flex flex-1 flex-col overflow-y-auto"
|
tradeOptions={tradeOptions}
|
||||||
sx={{ px: 3, py: 3 }}
|
submitError={drawerForm.submitError}
|
||||||
>
|
onCompanySelected={drawerForm.onCompanySelected}
|
||||||
<Stack spacing={3}>
|
onSubmit={drawerForm.handleSubmit(drawerForm.submit)}
|
||||||
{Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
|
/>
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="isActive"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={Boolean(field.value)}
|
|
||||||
onChange={field.onChange}
|
|
||||||
color="success"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={field.value ? "Active" : "Inactive"}
|
|
||||||
sx={{
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<VendorFormFields
|
|
||||||
control={control}
|
|
||||||
errors={errors}
|
|
||||||
companies={companies}
|
|
||||||
tradeOptions={tradeOptions}
|
|
||||||
onCompanySelected={onCompanySelected}
|
|
||||||
showTotalJobs
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
|
<VendorDrawerActions
|
||||||
<Stack
|
mode={mode}
|
||||||
direction="row"
|
mapsUrl={mapsUrl}
|
||||||
spacing={2}
|
pending={pending}
|
||||||
sx={{
|
canSave={drawerForm.hasChanges && drawerForm.isFormValid}
|
||||||
px: 3,
|
onCancel={drawerForm.cancel}
|
||||||
py: 2,
|
onEdit={() => onModeChange("edit")}
|
||||||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
{mode === "view" ? (
|
|
||||||
<>
|
|
||||||
{Boolean(mapsUrl) && (
|
|
||||||
<Tooltip title="Open in Google Maps">
|
|
||||||
<Button
|
|
||||||
component="a"
|
|
||||||
href={mapsUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<LaunchIcon />}
|
|
||||||
>
|
|
||||||
Maps
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Box className="flex-1" />
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => onModeChange("edit")}
|
|
||||||
startIcon={<EditOutlinedIcon />}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
onClick={handleCancel}
|
|
||||||
disabled={updateVendor.isPending}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
type="submit"
|
|
||||||
form="vendor-edit-form"
|
|
||||||
disabled={updateVendor.isPending || !isFormValid}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{updateVendor.isPending ? "Saving..." : "Save"}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Box className="flex flex-1 items-center justify-center">
|
<Box className="flex flex-1 items-center justify-center">
|
||||||
|
|
@ -457,30 +110,3 @@ export function VendorDetailDrawer({
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Typography
|
|
||||||
variant="subtitle2"
|
|
||||||
className="font-semibold uppercase"
|
|
||||||
sx={{ color: "text.secondary", mb: 1 }}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
<Stack spacing={1.5}>{children}</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailField({ label, value }: { label: string; value: string }) {
|
|
||||||
const display = value && value.trim() !== "" ? value : "—";
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">{display}</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Popover } from "@mui/material";
|
import { Popover, Skeleton } from "@mui/material";
|
||||||
import { ChevronDown, UserX, Users } from "lucide-react";
|
import { ChevronDown, UserX, Users } from "lucide-react";
|
||||||
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 type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
import {
|
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||||
MOCK_DISPATCHER_USERS,
|
|
||||||
getDispatcherAvatarColor,
|
|
||||||
} from "@/domain/work-orders/mocks/dispatcher-filter-mock-data";
|
|
||||||
import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item";
|
import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item";
|
||||||
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
|
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
|
||||||
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
||||||
|
|
@ -15,6 +12,7 @@ type DispatcherFilterProps = {
|
||||||
selectedIds: Set<string>;
|
selectedIds: Set<string>;
|
||||||
onChange: (ids: Set<string>) => void;
|
onChange: (ids: Set<string>) => void;
|
||||||
users: WorkOrderUser[];
|
users: WorkOrderUser[];
|
||||||
|
usersLoading?: boolean;
|
||||||
currentUserId?: string | number;
|
currentUserId?: string | number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -40,6 +38,7 @@ export function DispatcherFilter({
|
||||||
selectedIds,
|
selectedIds,
|
||||||
onChange,
|
onChange,
|
||||||
users,
|
users,
|
||||||
|
usersLoading = false,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
}: DispatcherFilterProps) {
|
}: DispatcherFilterProps) {
|
||||||
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||||
|
|
@ -57,11 +56,7 @@ export function DispatcherFilter({
|
||||||
}, [allSelected, meOnly, unassignedOnly, selectedIds.size]);
|
}, [allSelected, meOnly, unassignedOnly, selectedIds.size]);
|
||||||
|
|
||||||
const dispatchers = useMemo(() => {
|
const dispatchers = useMemo(() => {
|
||||||
const byId = new Map<string, WorkOrderUser>();
|
return [...users].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
for (const user of [...MOCK_DISPATCHER_USERS, ...users]) {
|
|
||||||
byId.set(String(user.id), user);
|
|
||||||
}
|
|
||||||
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
}, [users]);
|
}, [users]);
|
||||||
|
|
||||||
const toggle = (id: string) => {
|
const toggle = (id: string) => {
|
||||||
|
|
@ -74,6 +69,18 @@ export function DispatcherFilter({
|
||||||
onChange(next);
|
onChange(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (usersLoading && users.length === 0) {
|
||||||
|
return (
|
||||||
|
<Skeleton
|
||||||
|
variant="rounded"
|
||||||
|
width={160}
|
||||||
|
height={38}
|
||||||
|
animation="wave"
|
||||||
|
aria-label="Loading dispatchers"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MenuAnchorButton
|
<MenuAnchorButton
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ type WorkOrdersFilterBarProps = {
|
||||||
typeFilter: WorkOrderTypeFilter;
|
typeFilter: WorkOrderTypeFilter;
|
||||||
onTypeChange: (value: WorkOrderTypeFilter) => void;
|
onTypeChange: (value: WorkOrderTypeFilter) => void;
|
||||||
users: WorkOrderUser[];
|
users: WorkOrderUser[];
|
||||||
|
usersLoading?: boolean;
|
||||||
currentUserId?: string | number;
|
currentUserId?: string | number;
|
||||||
filteredCount: number;
|
filteredCount: number;
|
||||||
scopeTotalCount: number;
|
scopeTotalCount: number;
|
||||||
|
|
@ -25,6 +26,7 @@ export function WorkOrdersFilterBar({
|
||||||
typeFilter,
|
typeFilter,
|
||||||
onTypeChange,
|
onTypeChange,
|
||||||
users,
|
users,
|
||||||
|
usersLoading = false,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
filteredCount,
|
filteredCount,
|
||||||
scopeTotalCount,
|
scopeTotalCount,
|
||||||
|
|
@ -37,6 +39,7 @@ export function WorkOrdersFilterBar({
|
||||||
selectedIds={dispatcherIds}
|
selectedIds={dispatcherIds}
|
||||||
onChange={onDispatcherChange}
|
onChange={onDispatcherChange}
|
||||||
users={users}
|
users={users}
|
||||||
|
usersLoading={usersLoading}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
/>
|
/>
|
||||||
<WeekNav weekMonday={weekMonday} onChange={onWeekChange} />
|
<WeekNav weekMonday={weekMonday} onChange={onWeekChange} />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
|
|
||||||
|
export function buildInlineDraftCreateInput(
|
||||||
|
draft: WorkOrderTableDraft,
|
||||||
|
users: WorkOrderUser[],
|
||||||
|
sites: LocationOption[],
|
||||||
|
): WorkOrderWizardDraft {
|
||||||
|
const assignee = users.find((u) => String(u.id) === draft.dispatcherId);
|
||||||
|
const location = sites.find((s) => String(s.id) === String(draft.locationId));
|
||||||
|
const locationName = draft.location || draft.site || location?.name || "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
locationId: String(draft.locationId || ""),
|
||||||
|
locationName,
|
||||||
|
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
|
||||||
|
woNumber: draft.woNumber,
|
||||||
|
type: draft.type,
|
||||||
|
assigneeId: draft.dispatcherId,
|
||||||
|
assigneeName: assignee?.name ?? "",
|
||||||
|
scheduledOn: draft.scheduledOn,
|
||||||
|
dueDate: draft.dueDate,
|
||||||
|
pm: draft.pm,
|
||||||
|
extraServices: draft.extraServices ?? [],
|
||||||
|
pmNote: draft.serviceNotes ?? "",
|
||||||
|
vendorId: draft.vendorId || "",
|
||||||
|
vendorName: draft.company,
|
||||||
|
techPhone: draft.techPhone,
|
||||||
|
vendorNotes: "",
|
||||||
|
apptStart: draft.apptStart,
|
||||||
|
apptEnd: draft.apptEnd,
|
||||||
|
status: draft.status || "Incomplete",
|
||||||
|
pocName: draft.pocName,
|
||||||
|
pocPhone: draft.pocPhone,
|
||||||
|
pocNotes: draft.pocNotes,
|
||||||
|
scheduleWeekOnly: draft.scheduleWeekOnly,
|
||||||
|
targetWeek: draft.targetWeek || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -119,7 +119,6 @@ export function DateCell({
|
||||||
<WeekPicker
|
<WeekPicker
|
||||||
selected={!value ? targetWeek : ""}
|
selected={!value ? targetWeek : ""}
|
||||||
onPick={(mon) => {
|
onPick={(mon) => {
|
||||||
onSave("");
|
|
||||||
onSaveWeek?.(mon);
|
onSaveWeek?.(mon);
|
||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function FieldLabel({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium"
|
||||||
|
style={{ color: "var(--color-text-secondary)" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
import { getDispatcherAvatarColor } from "@/domain/work-orders/mocks/dispatcher-filter-mock-data";
|
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||||
|
|
||||||
import { HighlightText } from "./highlight-text";
|
import { HighlightText } from "./highlight-text";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,10 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Flag, X } from "lucide-react";
|
import { Flag, X } from "lucide-react";
|
||||||
import { Popover } from "@mui/material";
|
import { Popover } from "@mui/material";
|
||||||
|
import { FLAG_COLORS } from "@/domain/work-orders/flag-colors";
|
||||||
const FLAG_COLORS: { name: string; value: string }[] = [
|
|
||||||
{ name: "Red", value: "#EF4444" },
|
|
||||||
{ name: "Orange", value: "#F97316" },
|
|
||||||
{ name: "Amber", value: "#F59E0B" },
|
|
||||||
{ name: "Yellow", value: "#EAB308" },
|
|
||||||
{ name: "Green", value: "#22C55E" },
|
|
||||||
{ name: "Teal", value: "#14B8A6" },
|
|
||||||
{ name: "Blue", value: "#3B82F6" },
|
|
||||||
{ name: "Purple", value: "#8B5CF6" },
|
|
||||||
];
|
|
||||||
|
|
||||||
type FlagCellProps = {
|
type FlagCellProps = {
|
||||||
color?: string;
|
color?: string | null;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onChange: (color: string | null) => void;
|
onChange: (color: string | null) => void;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,91 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
import { Check } from "lucide-react";
|
ServiceNotesDialog,
|
||||||
|
type ServiceNotesDetails,
|
||||||
import { Popover } from "@mui/material";
|
} from "@/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog";
|
||||||
|
import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text";
|
||||||
import { PmTypeIcon } from "../pm-type-icons";
|
import { HoverChevron } from "@/app/(protected)/workorders/_components/list/table/cells/hover-chevron";
|
||||||
|
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||||
import { HighlightText } from "./highlight-text";
|
import { PmTypeDetailsTrigger } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger";
|
||||||
|
import { PmTypePickerPopover } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover";
|
||||||
import { HoverChevron } from "./hover-chevron";
|
import {
|
||||||
|
hasServiceNotesDetails,
|
||||||
|
truncateServiceNotesPreview,
|
||||||
|
} from "@/domain/work-orders/utils/service-notes-ui";
|
||||||
|
|
||||||
type PMTypeCellProps = {
|
type PMTypeCellProps = {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
||||||
pmTypes: string[];
|
pmTypes: string[];
|
||||||
|
|
||||||
q?: string;
|
q?: string;
|
||||||
|
|
||||||
onSave: (v: string) => void;
|
onSave: (v: string) => void;
|
||||||
|
serviceNotes?: string;
|
||||||
|
extraServices?: string[];
|
||||||
|
woNumber?: string;
|
||||||
|
onSaveDetails?: (details: ServiceNotesDetails) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PMTypeCell({ value, pmTypes, q, onSave }: PMTypeCellProps) {
|
export function PMTypeCell({
|
||||||
|
value,
|
||||||
|
pmTypes,
|
||||||
|
q,
|
||||||
|
onSave,
|
||||||
|
serviceNotes = "",
|
||||||
|
extraServices = [],
|
||||||
|
woNumber,
|
||||||
|
onSaveDetails,
|
||||||
|
}: PMTypeCellProps) {
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
const filtered = search
|
const filtered = search
|
||||||
? pmTypes.filter((p) => p.toLowerCase().includes(search.toLowerCase()))
|
? pmTypes.filter((p) => p.toLowerCase().includes(search.toLowerCase()))
|
||||||
: pmTypes;
|
: pmTypes;
|
||||||
|
|
||||||
|
const extras = extraServices ?? [];
|
||||||
|
const hasDetails = hasServiceNotesDetails(serviceNotes, extras);
|
||||||
|
const notePreview = serviceNotes?.trim() ? truncateServiceNotesPreview(serviceNotes) : "";
|
||||||
|
|
||||||
|
const closePopover = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
setSearch("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal =
|
||||||
|
onSaveDetails != null ? (
|
||||||
|
<ServiceNotesDialog
|
||||||
|
open={modalOpen}
|
||||||
|
onOpenChange={setModalOpen}
|
||||||
|
pm={value}
|
||||||
|
extraServices={extras}
|
||||||
|
serviceNotes={serviceNotes ?? ""}
|
||||||
|
woNumber={woNumber}
|
||||||
|
pmTypes={pmTypes}
|
||||||
|
onSave={onSaveDetails}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (hasDetails && onSaveDetails) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PmTypeDetailsTrigger
|
||||||
|
value={value}
|
||||||
|
q={q}
|
||||||
|
notePreview={notePreview}
|
||||||
|
extrasCount={extras.length}
|
||||||
|
onOpenDetails={() => setModalOpen(true)}
|
||||||
|
/>
|
||||||
|
{modal}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
setAnchorEl(e.currentTarget);
|
setAnchorEl(e.currentTarget);
|
||||||
}}
|
}}
|
||||||
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
|
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
|
||||||
|
|
@ -46,13 +96,11 @@ export function PMTypeCell({ value, pmTypes, q, onSave }: PMTypeCellProps) {
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{value ? (
|
{value ? (
|
||||||
<span
|
<span
|
||||||
className="min-w-0 truncate"
|
className="min-w-0 truncate"
|
||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|
||||||
color: "var(--color-text-primary)",
|
color: "var(--color-text-primary)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -61,60 +109,27 @@ export function PMTypeCell({ value, pmTypes, q, onSave }: PMTypeCellProps) {
|
||||||
) : (
|
) : (
|
||||||
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>Select …</span>
|
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>Select …</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<HoverChevron />
|
<HoverChevron />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Popover
|
<PmTypePickerPopover
|
||||||
open={Boolean(anchorEl)}
|
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
onClose={() => {
|
search={search}
|
||||||
setAnchorEl(null);
|
filtered={filtered}
|
||||||
|
value={value}
|
||||||
setSearch("");
|
showAddDetails={Boolean(onSaveDetails)}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
onClose={closePopover}
|
||||||
|
onPick={(pm) => {
|
||||||
|
onSave(pm);
|
||||||
|
closePopover();
|
||||||
}}
|
}}
|
||||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
onAddDetails={() => {
|
||||||
onClick={(e) => e.stopPropagation()}
|
closePopover();
|
||||||
slotProps={{ paper: { className: "w-[244px] rounded-lg border border-border p-1.5" } }}
|
setModalOpen(true);
|
||||||
>
|
}}
|
||||||
<div className="mb-1.5 border-b border-border px-1 pb-1.5">
|
/>
|
||||||
<input
|
{modal}
|
||||||
autoFocus
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
placeholder="Search PM type…"
|
|
||||||
className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-52 overflow-y-auto">
|
|
||||||
{filtered.map((pm) => (
|
|
||||||
<button
|
|
||||||
key={pm}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onSave(pm);
|
|
||||||
|
|
||||||
setAnchorEl(null);
|
|
||||||
|
|
||||||
setSearch("");
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
|
|
||||||
>
|
|
||||||
<PmTypeIcon pmType={pm} size={12} style={{ color: "var(--color-text-muted)" }} />
|
|
||||||
|
|
||||||
<span
|
|
||||||
className="flex-1 truncate text-left text-xs"
|
|
||||||
style={{ color: "var(--color-text-primary)" }}
|
|
||||||
>
|
|
||||||
{pm}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{value === pm && <Check size={10} className="text-[var(--color-primary)]" />}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text";
|
||||||
|
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||||
|
|
||||||
|
type PmTypeDetailsTriggerProps = {
|
||||||
|
value: string;
|
||||||
|
q?: string;
|
||||||
|
notePreview: string;
|
||||||
|
extrasCount: number;
|
||||||
|
onOpenDetails: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PmTypeDetailsTrigger({
|
||||||
|
value,
|
||||||
|
q,
|
||||||
|
notePreview,
|
||||||
|
extrasCount,
|
||||||
|
onOpenDetails,
|
||||||
|
}: PmTypeDetailsTriggerProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenDetails();
|
||||||
|
}}
|
||||||
|
className="flex w-full min-w-0 flex-col items-start text-left leading-tight transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
<span className="flex w-full min-w-0 items-center gap-1.5">
|
||||||
|
<PmTypeIcon
|
||||||
|
pmType={value}
|
||||||
|
size={12}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="min-w-0 truncate"
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: value ? "var(--color-text-primary)" : "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value ? <HighlightText text={value} q={q} /> : "Select …"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{notePreview ? (
|
||||||
|
<span
|
||||||
|
className="w-full min-w-0 truncate"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontStyle: "italic",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
paddingLeft: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{notePreview}
|
||||||
|
</span>
|
||||||
|
) : extrasCount > 0 ? (
|
||||||
|
<span
|
||||||
|
className="w-full min-w-0 truncate"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
paddingLeft: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+{extrasCount} more
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { Check, Plus } from "lucide-react";
|
||||||
|
import { Popover } from "@mui/material";
|
||||||
|
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||||
|
|
||||||
|
type PmTypePickerPopoverProps = {
|
||||||
|
anchorEl: HTMLElement | null;
|
||||||
|
search: string;
|
||||||
|
filtered: string[];
|
||||||
|
value: string;
|
||||||
|
showAddDetails: boolean;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onPick: (pm: string) => void;
|
||||||
|
onAddDetails: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PmTypePickerPopover({
|
||||||
|
anchorEl,
|
||||||
|
search,
|
||||||
|
filtered,
|
||||||
|
value,
|
||||||
|
showAddDetails,
|
||||||
|
onSearchChange,
|
||||||
|
onClose,
|
||||||
|
onPick,
|
||||||
|
onAddDetails,
|
||||||
|
}: PmTypePickerPopoverProps) {
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={onClose}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
slotProps={{ paper: { className: "w-[244px] rounded-lg border border-border p-1.5" } }}
|
||||||
|
>
|
||||||
|
<div className="mb-1.5 border-b border-border px-1 pb-1.5">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
placeholder="Search PM type…"
|
||||||
|
className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-52 overflow-y-auto">
|
||||||
|
{showAddDetails && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddDetails}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] font-medium transition-colors"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = "var(--color-primary-tint)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={12} className="shrink-0" /> Add service and notes
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{filtered.map((pm) => (
|
||||||
|
<button
|
||||||
|
key={pm}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick(pm)}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
|
||||||
|
>
|
||||||
|
<PmTypeIcon pmType={pm} size={12} style={{ color: "var(--color-text-muted)" }} />
|
||||||
|
<span
|
||||||
|
className="flex-1 truncate text-left text-xs"
|
||||||
|
style={{ color: "var(--color-text-primary)" }}
|
||||||
|
>
|
||||||
|
{pm}
|
||||||
|
</span>
|
||||||
|
{value === pm && <Check size={10} className="text-[var(--color-primary)]" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Wrench } from "lucide-react";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||||
|
import { WizardServiceNotesFields } from "@/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
|
|
||||||
|
export type ServiceNotesDetails = {
|
||||||
|
pm: string;
|
||||||
|
extraServices: string[];
|
||||||
|
serviceNotes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ServiceNotesDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
pm: string;
|
||||||
|
extraServices: string[];
|
||||||
|
serviceNotes: string;
|
||||||
|
woNumber?: string;
|
||||||
|
pmTypes: string[];
|
||||||
|
onSave: (details: ServiceNotesDetails) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ServiceNotesDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
pm,
|
||||||
|
extraServices,
|
||||||
|
serviceNotes,
|
||||||
|
woNumber,
|
||||||
|
pmTypes,
|
||||||
|
onSave,
|
||||||
|
}: ServiceNotesDialogProps) {
|
||||||
|
const [sel, setSel] = useState<string[]>([]);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSel([pm, ...(extraServices ?? [])].filter((p, i, a) => !!p && a.indexOf(p) === i));
|
||||||
|
setNote(serviceNotes ?? "");
|
||||||
|
setSearch("");
|
||||||
|
}, [open, pm, extraServices, serviceNotes]);
|
||||||
|
|
||||||
|
const toggle = (value: string) => {
|
||||||
|
setSel((cur) => (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const services = pmTypes.map((value) => ({ id: value, value }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={() => onOpenChange(false)}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
slotProps={dialogBlurBackdropSlotProps}
|
||||||
|
>
|
||||||
|
<DialogTitle
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
style={{ fontSize: 16, color: "var(--color-text-primary)" }}
|
||||||
|
>
|
||||||
|
<Wrench size={16} style={{ color: "var(--color-text-muted)" }} />
|
||||||
|
Service & Notes
|
||||||
|
{Boolean(woNumber) && (
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 400, color: "var(--color-text-muted)" }}>
|
||||||
|
#{woNumber}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent
|
||||||
|
className="space-y-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ paddingTop: 8 }}
|
||||||
|
>
|
||||||
|
<WizardServiceNotesFields
|
||||||
|
services={services}
|
||||||
|
sel={sel}
|
||||||
|
onToggle={toggle}
|
||||||
|
note={note}
|
||||||
|
setNote={setNote}
|
||||||
|
search={search}
|
||||||
|
setSearch={setSearch}
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="min-h-0 px-2 py-1 text-[12px]"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="min-h-0 px-3 py-1 text-[12px]"
|
||||||
|
onClick={() => {
|
||||||
|
onSave({
|
||||||
|
pm: sel[0] ?? "",
|
||||||
|
extraServices: sel.slice(1),
|
||||||
|
serviceNotes: note,
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { MapPin } from "lucide-react";
|
import { MapPin } from "lucide-react";
|
||||||
|
import { formatSitePlace } from "@/domain/locations/mappers/location-mapper";
|
||||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
import { HighlightText } from "./highlight-text";
|
import { HighlightText } from "./highlight-text";
|
||||||
import { HoverChevron } from "./hover-chevron";
|
import { HoverChevron } from "./hover-chevron";
|
||||||
|
|
@ -9,6 +10,8 @@ type SiteCellProps = {
|
||||||
value: string;
|
value: string;
|
||||||
location: string;
|
location: string;
|
||||||
locationId: string | number;
|
locationId: string | number;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
pocName?: string;
|
pocName?: string;
|
||||||
pocPhone?: string;
|
pocPhone?: string;
|
||||||
pocNotes?: string;
|
pocNotes?: string;
|
||||||
|
|
@ -21,6 +24,8 @@ export function SiteCell({
|
||||||
value,
|
value,
|
||||||
location,
|
location,
|
||||||
locationId,
|
locationId,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
pocName,
|
pocName,
|
||||||
pocPhone,
|
pocPhone,
|
||||||
pocNotes,
|
pocNotes,
|
||||||
|
|
@ -29,6 +34,7 @@ export function SiteCell({
|
||||||
onSave,
|
onSave,
|
||||||
}: SiteCellProps) {
|
}: SiteCellProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const place = formatSitePlace(city, state, location);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -49,11 +55,16 @@ export function SiteCell({
|
||||||
>
|
>
|
||||||
<HighlightText text={value} q={q} />
|
<HighlightText text={value} q={q} />
|
||||||
</span>
|
</span>
|
||||||
{Boolean(location) && (
|
{Boolean(place) && (
|
||||||
<>
|
<>
|
||||||
<span className="shrink-0 text-[var(--color-text-muted)]">·</span>
|
<span
|
||||||
|
className="inline-flex shrink-0 items-center self-center text-[12.5px] leading-none text-[var(--color-text-muted)]"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
.
|
||||||
|
</span>
|
||||||
<span className="truncate text-xs text-[var(--muted-foreground)]">
|
<span className="truncate text-xs text-[var(--muted-foreground)]">
|
||||||
<HighlightText text={location} q={q} />
|
<HighlightText text={place} q={q} />
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { ExternalLink, Mail, MapPin, Phone } from "lucide-react";
|
||||||
|
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
import { buildMapsUrl } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
|
||||||
|
|
||||||
|
type SiteDialogDetailPanelProps = {
|
||||||
|
locationDetail: Location | undefined;
|
||||||
|
locationDetailLoading: boolean;
|
||||||
|
locationDetailError: boolean;
|
||||||
|
addressPreview: string;
|
||||||
|
fallbackLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SiteDialogSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5" aria-busy="true" role="status" aria-label="Loading site details">
|
||||||
|
<span
|
||||||
|
className="block animate-pulse rounded"
|
||||||
|
style={{ background: "var(--color-bg-muted)", width: "75%", height: 12 }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="block animate-pulse rounded"
|
||||||
|
style={{ background: "var(--color-bg-muted)", width: "50%", height: 12 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteDialogDetailPanel({
|
||||||
|
locationDetail,
|
||||||
|
locationDetailLoading,
|
||||||
|
locationDetailError,
|
||||||
|
addressPreview,
|
||||||
|
fallbackLabel,
|
||||||
|
}: SiteDialogDetailPanelProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="space-y-1.5 rounded-lg p-3"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{locationDetail ? (
|
||||||
|
<>
|
||||||
|
<Detail icon={MapPin} text={addressPreview || fallbackLabel} />
|
||||||
|
{Boolean(locationDetail.phone) && (
|
||||||
|
<Detail icon={Phone} text={locationDetail.phone ?? ""} />
|
||||||
|
)}
|
||||||
|
{Boolean(locationDetail.contactEmail) && (
|
||||||
|
<Detail icon={Mail} text={locationDetail.contactEmail ?? ""} />
|
||||||
|
)}
|
||||||
|
{Boolean(addressPreview) && (
|
||||||
|
<a
|
||||||
|
href={buildMapsUrl(locationDetail)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
<ExternalLink size={12} /> Open in Google Maps
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Detail icon={MapPin} text={fallbackLabel} />
|
||||||
|
{locationDetailLoading && <SiteDialogSkeleton />}
|
||||||
|
{locationDetailError && (
|
||||||
|
<Text as="p" style={{ fontSize: 12, color: "var(--muted-foreground)" }}>
|
||||||
|
Could not load full site details. City and site code above are still available.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
||||||
|
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
export function extractLocationLabel(site: LocationOption): string {
|
||||||
|
const place = [site.city, site.state].filter(Boolean).join(", ");
|
||||||
|
if (place) {
|
||||||
|
return place;
|
||||||
|
}
|
||||||
|
const name = site.name;
|
||||||
|
for (const sep of [" · ", " — ", " - "]) {
|
||||||
|
const idx = name.indexOf(sep);
|
||||||
|
if (idx > 0) {
|
||||||
|
return name.slice(idx + sep.length).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Board rows often have siteCode but no locationId — resolve via sites list. */
|
||||||
|
export function resolveLocationId(
|
||||||
|
locationId: string | number,
|
||||||
|
siteCode: string,
|
||||||
|
sites: LocationOption[],
|
||||||
|
): string {
|
||||||
|
const idStr = locationId !== "" && locationId != null ? String(locationId) : "";
|
||||||
|
if (idStr && sites.some((s) => String(s.id) === idStr)) {
|
||||||
|
return idStr;
|
||||||
|
}
|
||||||
|
if (idStr && sites.length === 0) {
|
||||||
|
return idStr;
|
||||||
|
}
|
||||||
|
const code = siteCode.trim().toLowerCase();
|
||||||
|
if (!code) {
|
||||||
|
return idStr;
|
||||||
|
}
|
||||||
|
const byCode = sites.find((s) => resolveSiteCode(s).toLowerCase() === code);
|
||||||
|
return byCode ? String(byCode.id) : idStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMapsUrl(location: Location): string {
|
||||||
|
const query = formatLocationAddressPreview(location);
|
||||||
|
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import {
|
||||||
|
DLG_INPUT_CLS,
|
||||||
|
DLG_INPUT_STYLE,
|
||||||
|
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||||
|
|
||||||
|
type SiteDialogPocFieldsProps = {
|
||||||
|
pocName: string;
|
||||||
|
pocPhone: string;
|
||||||
|
pocNotes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SiteDialogPocFields({ pocName, pocPhone, pocNotes }: SiteDialogPocFieldsProps) {
|
||||||
|
return (
|
||||||
|
<div className="pt-1">
|
||||||
|
<FieldLabel>Point of Contact</FieldLabel>
|
||||||
|
<Text as="p" className="mb-2 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
Shown from site/work order records. Board edit saves site code only.
|
||||||
|
</Text>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
value={pocName}
|
||||||
|
readOnly
|
||||||
|
placeholder="POC name"
|
||||||
|
className={DLG_INPUT_CLS}
|
||||||
|
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={pocPhone}
|
||||||
|
readOnly
|
||||||
|
placeholder="POC phone"
|
||||||
|
className={DLG_INPUT_CLS}
|
||||||
|
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={pocNotes}
|
||||||
|
readOnly
|
||||||
|
placeholder="Notes…"
|
||||||
|
rows={2}
|
||||||
|
className={`${DLG_INPUT_CLS} resize-none`}
|
||||||
|
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit", opacity: 0.85 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,21 @@
|
||||||
import { useEffect, useState, type MouseEvent, type ReactNode } from "react";
|
import { useEffect, useState, type MouseEvent } from "react";
|
||||||
import { ExternalLink, Mail, MapPin, Phone } from "lucide-react";
|
import { MapPin } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||||
import {
|
|
||||||
DLG_INPUT_CLS,
|
|
||||||
DLG_INPUT_STYLE,
|
|
||||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
|
||||||
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
import type { Location } from "@/domain/locations/types/location";
|
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
||||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||||
import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
|
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||||
|
import {
|
||||||
|
extractLocationLabel,
|
||||||
|
resolveLocationId,
|
||||||
|
} from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
|
||||||
|
import { SiteDialogDetailPanel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel";
|
||||||
|
import { SiteDialogPocFields } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields";
|
||||||
|
|
||||||
export type SitePatch = {
|
export type SitePatch = {
|
||||||
site: string;
|
site: string;
|
||||||
|
|
@ -36,40 +38,6 @@ type SiteDialogProps = {
|
||||||
onSave: (patch: SitePatch) => void;
|
onSave: (patch: SitePatch) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function FieldLabel({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium"
|
|
||||||
style={{ color: "var(--color-text-secondary)" }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractSiteCode(name: string): string {
|
|
||||||
const dash = name.indexOf(" — ");
|
|
||||||
if (dash > 0) {
|
|
||||||
return name.slice(0, dash).trim();
|
|
||||||
}
|
|
||||||
return name.slice(0, 8).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractLocationLabel(name: string): string {
|
|
||||||
const dash = name.indexOf(" — ");
|
|
||||||
if (dash > 0) {
|
|
||||||
return name.slice(dash + 3).trim();
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMapsUrl(location: Location): string {
|
|
||||||
const parts = [location.address, location.city, location.state, location.zipCode]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ");
|
|
||||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(parts)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SiteDialog({
|
export function SiteDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|
@ -82,28 +50,34 @@ export function SiteDialog({
|
||||||
onSave,
|
onSave,
|
||||||
}: SiteDialogProps) {
|
}: SiteDialogProps) {
|
||||||
const [code, setCode] = useState(value);
|
const [code, setCode] = useState(value);
|
||||||
const [locId, setLocId] = useState(String(locationId || ""));
|
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
|
||||||
const [pn, setPn] = useState(pocName);
|
const [pn, setPn] = useState(pocName);
|
||||||
const [pp, setPp] = useState(pocPhone);
|
const [pp, setPp] = useState(pocPhone);
|
||||||
const [notes, setNotes] = useState(pocNotes);
|
const [notes, setNotes] = useState(pocNotes);
|
||||||
const [pocFilledFor, setPocFilledFor] = useState("");
|
const [pocFilledFor, setPocFilledFor] = useState("");
|
||||||
|
|
||||||
const isMockLocation = locId.startsWith("mock-");
|
const {
|
||||||
const { data: apiLocationDetail } = useLocationDetail(
|
data: locationDetail,
|
||||||
isMockLocation || !locId ? undefined : locId,
|
isLoading: locationDetailLoading,
|
||||||
);
|
isError: locationDetailError,
|
||||||
const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail;
|
} = useLocationDetail(open && locId ? locId : undefined);
|
||||||
|
|
||||||
|
const selected = sites.find((s) => String(s.id) === locId);
|
||||||
|
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (!open) {
|
||||||
setCode(value);
|
return;
|
||||||
setLocId(String(locationId || ""));
|
|
||||||
setPn(pocName);
|
|
||||||
setPp(pocPhone);
|
|
||||||
setNotes(pocNotes);
|
|
||||||
setPocFilledFor(String(locationId || ""));
|
|
||||||
}
|
}
|
||||||
}, [open, value, locationId, pocName, pocPhone, pocNotes]);
|
const resolved = resolveLocationId(locationId, value, sites);
|
||||||
|
setCode(value);
|
||||||
|
setLocId(resolved);
|
||||||
|
setPn(pocName);
|
||||||
|
setPp(pocPhone);
|
||||||
|
setNotes(pocNotes);
|
||||||
|
// Preserve existing WO POC; allow one auto-fill from site detail when POC is empty.
|
||||||
|
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
|
||||||
|
}, [open, value, locationId, pocName, pocPhone, pocNotes, sites]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
|
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
|
||||||
|
|
@ -114,13 +88,11 @@ export function SiteDialog({
|
||||||
setPocFilledFor(locId);
|
setPocFilledFor(locId);
|
||||||
}, [open, locId, locationDetail, pocFilledFor]);
|
}, [open, locId, locationDetail, pocFilledFor]);
|
||||||
|
|
||||||
const selected = sites.find((s) => String(s.id) === locId);
|
|
||||||
|
|
||||||
const handlePick = (id: string) => {
|
const handlePick = (id: string) => {
|
||||||
const next = sites.find((s) => String(s.id) === id);
|
const next = sites.find((s) => String(s.id) === id);
|
||||||
setLocId(id);
|
setLocId(id);
|
||||||
if (next) {
|
if (next) {
|
||||||
setCode(extractSiteCode(next.name));
|
setCode(resolveSiteCode(next));
|
||||||
}
|
}
|
||||||
if (id !== pocFilledFor) {
|
if (id !== pocFilledFor) {
|
||||||
setPocFilledFor("");
|
setPocFilledFor("");
|
||||||
|
|
@ -171,79 +143,17 @@ export function SiteDialog({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{locationDetail != null && Boolean(locId) && (
|
{Boolean(locId) && (
|
||||||
<div
|
<SiteDialogDetailPanel
|
||||||
className="space-y-1.5 rounded-lg p-3"
|
locationDetail={locationDetail}
|
||||||
style={{
|
locationDetailLoading={locationDetailLoading}
|
||||||
background: "var(--color-bg-muted)",
|
locationDetailError={locationDetailError}
|
||||||
border: "1px solid var(--color-border)",
|
addressPreview={addressPreview}
|
||||||
}}
|
fallbackLabel={selected?.name || code || "—"}
|
||||||
>
|
/>
|
||||||
<Detail
|
|
||||||
icon={MapPin}
|
|
||||||
text={
|
|
||||||
[
|
|
||||||
locationDetail.address,
|
|
||||||
locationDetail.city,
|
|
||||||
locationDetail.state,
|
|
||||||
locationDetail.zipCode,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ") || "—"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{locationDetail.phone != null && <Detail icon={Phone} text={locationDetail.phone} />}
|
|
||||||
{locationDetail.contactEmail != null && (
|
|
||||||
<Detail icon={Mail} text={locationDetail.contactEmail} />
|
|
||||||
)}
|
|
||||||
{Boolean(locationDetail.address) && (
|
|
||||||
<a
|
|
||||||
href={buildMapsUrl(locationDetail)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
|
||||||
style={{ color: "var(--color-primary)" }}
|
|
||||||
>
|
|
||||||
<ExternalLink size={12} /> Open in Google Maps
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="pt-1">
|
<SiteDialogPocFields pocName={pn} pocPhone={pp} pocNotes={notes} />
|
||||||
<FieldLabel>Point of Contact</FieldLabel>
|
|
||||||
<Text
|
|
||||||
as="p"
|
|
||||||
className="mb-2"
|
|
||||||
style={{ fontSize: 11, color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
Auto-filled from records — edit if outdated
|
|
||||||
</Text>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<input
|
|
||||||
value={pn}
|
|
||||||
onChange={(e) => setPn(e.target.value)}
|
|
||||||
placeholder="POC name"
|
|
||||||
className={DLG_INPUT_CLS}
|
|
||||||
style={DLG_INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={pp}
|
|
||||||
onChange={(e) => setPp(e.target.value)}
|
|
||||||
placeholder="POC phone"
|
|
||||||
className={DLG_INPUT_CLS}
|
|
||||||
style={DLG_INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
value={notes}
|
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
|
||||||
placeholder="Notes…"
|
|
||||||
rows={2}
|
|
||||||
className={`${DLG_INPUT_CLS} resize-none`}
|
|
||||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-end gap-2">
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
|
|
@ -258,8 +168,8 @@ export function SiteDialog({
|
||||||
className="min-h-0 px-3 py-1 text-[12px]"
|
className="min-h-0 px-3 py-1 text-[12px]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSave({
|
onSave({
|
||||||
site: code || (selected ? extractSiteCode(selected.name) : ""),
|
site: code || (selected ? resolveSiteCode(selected) : ""),
|
||||||
location: selected ? extractLocationLabel(selected.name) : "",
|
location: selected ? extractLocationLabel(selected) : "",
|
||||||
locationId: locId,
|
locationId: locId,
|
||||||
pocName: pn,
|
pocName: pn,
|
||||||
pocPhone: pp,
|
pocPhone: pp,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { ExternalLink, Mail, User, Wrench } from "lucide-react";
|
||||||
|
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||||
|
|
||||||
|
type VendorDialogCompanyCardProps = {
|
||||||
|
company: string;
|
||||||
|
vendor: VendorDropdownItem | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VendorDialogCompanyCard({ company, vendor }: VendorDialogCompanyCardProps) {
|
||||||
|
const distance = formatVendorDistance(vendor?.distanceMiles);
|
||||||
|
const email = vendor?.email?.trim() || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="space-y-1.5 rounded-lg p-3"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
|
||||||
|
{company || "—"}
|
||||||
|
</div>
|
||||||
|
{Boolean(distance) && (
|
||||||
|
<span className="shrink-0 text-[11px]" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{distance}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{Boolean(vendor?.contactName) && <Detail icon={User} text={vendor?.contactName ?? ""} />}
|
||||||
|
{Boolean(vendor?.tradeSpecialties) && (
|
||||||
|
<Detail icon={Wrench} text={vendor?.tradeSpecialties ?? ""} />
|
||||||
|
)}
|
||||||
|
{Boolean(email) && <Detail icon={Mail} text={email} />}
|
||||||
|
{vendor != null && Boolean(vendor.address) && (
|
||||||
|
<a
|
||||||
|
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(vendor.address)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
<ExternalLink size={12} /> Open in Google Maps
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { formatVendorTechnicianLabel } from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||||
|
|
||||||
|
export type TechnicianOption = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
company: string;
|
||||||
|
phone: string;
|
||||||
|
vendor?: VendorDropdownItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildTechnicianOptions(
|
||||||
|
vendors: VendorDropdownItem[],
|
||||||
|
tech: string,
|
||||||
|
company: string,
|
||||||
|
): TechnicianOption[] {
|
||||||
|
const options = new Map<string, TechnicianOption>();
|
||||||
|
|
||||||
|
const add = (option: TechnicianOption) => {
|
||||||
|
if (!option.key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.set(option.key, option);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const vendor of vendors) {
|
||||||
|
const contact = vendor.contactName.trim() || vendor.companyName.trim();
|
||||||
|
add({
|
||||||
|
key: String(vendor.id),
|
||||||
|
label: formatVendorTechnicianLabel(vendor),
|
||||||
|
name: contact,
|
||||||
|
company: vendor.companyName,
|
||||||
|
phone: "",
|
||||||
|
vendor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tech) {
|
||||||
|
const vendor =
|
||||||
|
vendors.find((v) => v.contactName === tech && v.companyName === company) ??
|
||||||
|
vendors.find((v) => v.contactName === tech) ??
|
||||||
|
vendors.find((v) => v.companyName === company);
|
||||||
|
const key = vendor ? String(vendor.id) : tech;
|
||||||
|
add({
|
||||||
|
key,
|
||||||
|
label: company && company !== tech ? `${tech} · ${company}` : tech,
|
||||||
|
name: tech,
|
||||||
|
company: company || tech,
|
||||||
|
phone: "",
|
||||||
|
vendor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(options.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function phoneForOption(name: string, options: TechnicianOption[], fallback = ""): string {
|
||||||
|
return options.find((o) => o.name === name)?.phone ?? fallback;
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,24 @@
|
||||||
import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||||
import { ExternalLink, Mail, Wrench } from "lucide-react";
|
import { Wrench } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||||
import {
|
import {
|
||||||
DLG_INPUT_CLS,
|
DLG_INPUT_CLS,
|
||||||
DLG_INPUT_STYLE,
|
DLG_INPUT_STYLE,
|
||||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||||
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||||
|
import {
|
||||||
|
buildTechnicianOptions,
|
||||||
|
phoneForOption,
|
||||||
|
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||||
|
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
|
||||||
|
|
||||||
export type VendorPatch = {
|
export type VendorPatch = {
|
||||||
|
vendorId: string;
|
||||||
company: string;
|
company: string;
|
||||||
tech: string;
|
tech: string;
|
||||||
techPhone: string;
|
techPhone: string;
|
||||||
|
|
@ -28,90 +34,6 @@ type VendorDialogProps = {
|
||||||
onSave: (patch: VendorPatch) => void;
|
onSave: (patch: VendorPatch) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TechnicianOption = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
name: string;
|
|
||||||
company: string;
|
|
||||||
phone: string;
|
|
||||||
vendor?: VendorDropdownItem;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Known tech ↔ company pairs from seeded table mock data. */
|
|
||||||
const MOCK_TECH_BY_COMPANY: Record<string, { name: string; phone: string }> = {
|
|
||||||
"Midwest HVAC Co.": { name: "Mike Torres", phone: "(312) 555-0198" },
|
|
||||||
"Great Lakes Plumbing": { name: "Jen Walsh", phone: "(317) 555-4412" },
|
|
||||||
"SparkLine Electric": { name: "Pat Nguyen", phone: "" },
|
|
||||||
};
|
|
||||||
|
|
||||||
function FieldLabel({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium"
|
|
||||||
style={{ color: "var(--color-text-secondary)" }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTechnicianOptions(
|
|
||||||
vendors: VendorDropdownItem[],
|
|
||||||
tech: string,
|
|
||||||
company: string,
|
|
||||||
): TechnicianOption[] {
|
|
||||||
const options = new Map<string, TechnicianOption>();
|
|
||||||
|
|
||||||
const add = (option: TechnicianOption) => {
|
|
||||||
if (!option.key) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
options.set(option.key, option);
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const vendor of vendors) {
|
|
||||||
const mockTech = MOCK_TECH_BY_COMPANY[vendor.companyName];
|
|
||||||
if (mockTech) {
|
|
||||||
add({
|
|
||||||
key: mockTech.name,
|
|
||||||
label: `${mockTech.name} · ${vendor.companyName}`,
|
|
||||||
name: mockTech.name,
|
|
||||||
company: vendor.companyName,
|
|
||||||
phone: mockTech.phone,
|
|
||||||
vendor,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
add({
|
|
||||||
key: vendor.companyName,
|
|
||||||
label: vendor.companyName,
|
|
||||||
name: vendor.companyName,
|
|
||||||
company: vendor.companyName,
|
|
||||||
phone: "",
|
|
||||||
vendor,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tech) {
|
|
||||||
const vendor = vendors.find((v) => v.companyName === company);
|
|
||||||
add({
|
|
||||||
key: tech,
|
|
||||||
label: company && company !== tech ? `${tech} · ${company}` : tech,
|
|
||||||
name: tech,
|
|
||||||
company: company || tech,
|
|
||||||
phone: "",
|
|
||||||
vendor,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(options.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
function phoneForOption(name: string, options: TechnicianOption[], fallback = ""): string {
|
|
||||||
return options.find((o) => o.name === name)?.phone ?? fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VendorDialog({
|
export function VendorDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|
@ -138,12 +60,16 @@ export function VendorDialog({
|
||||||
}
|
}
|
||||||
}, [open, tech, techPhone, technicianOptions]);
|
}, [open, tech, techPhone, technicianOptions]);
|
||||||
|
|
||||||
const selected = technicianOptions.find((o) => o.name === t);
|
const selected =
|
||||||
|
technicianOptions.find((o) => o.name === t && (!company || o.company === company)) ??
|
||||||
|
technicianOptions.find((o) => o.name === t || o.key === t);
|
||||||
|
const selectedKey = selected?.key ?? "";
|
||||||
const resolvedCompany = t ? (selected?.company ?? company) : "";
|
const resolvedCompany = t ? (selected?.company ?? company) : "";
|
||||||
const selectedVendor = selected?.vendor ?? vendors.find((v) => v.companyName === resolvedCompany);
|
const selectedVendor = selected?.vendor ?? vendors.find((v) => v.companyName === resolvedCompany);
|
||||||
const companyEmail = selectedVendor?.email?.trim() || "";
|
|
||||||
|
|
||||||
const handlePick = (name: string) => {
|
const handlePick = (key: string) => {
|
||||||
|
const option = technicianOptions.find((o) => o.key === key);
|
||||||
|
const name = option?.name ?? key;
|
||||||
setT(name);
|
setT(name);
|
||||||
setPhone(phoneForOption(name, technicianOptions));
|
setPhone(phoneForOption(name, technicianOptions));
|
||||||
};
|
};
|
||||||
|
|
@ -174,7 +100,7 @@ export function VendorDialog({
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>Technician</FieldLabel>
|
<FieldLabel>Technician</FieldLabel>
|
||||||
<WizardFieldSelect
|
<WizardFieldSelect
|
||||||
value={t}
|
value={selectedKey}
|
||||||
placeholder="Search technician …"
|
placeholder="Search technician …"
|
||||||
icon={
|
icon={
|
||||||
<Wrench
|
<Wrench
|
||||||
|
|
@ -208,29 +134,7 @@ export function VendorDialog({
|
||||||
{Boolean(t) && (
|
{Boolean(t) && (
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>Company</FieldLabel>
|
<FieldLabel>Company</FieldLabel>
|
||||||
<div
|
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
|
||||||
className="space-y-1.5 rounded-lg p-3"
|
|
||||||
style={{
|
|
||||||
background: "var(--color-bg-muted)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
|
|
||||||
{resolvedCompany || "—"}
|
|
||||||
</div>
|
|
||||||
{Boolean(companyEmail) && <Detail icon={Mail} text={companyEmail} />}
|
|
||||||
{selectedVendor != null && selectedVendor.address != null && (
|
|
||||||
<a
|
|
||||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(selectedVendor.address)}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
|
||||||
style={{ color: "var(--color-primary)" }}
|
|
||||||
>
|
|
||||||
<ExternalLink size={12} /> Open in Google Maps
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -258,7 +162,12 @@ export function VendorDialog({
|
||||||
<Button
|
<Button
|
||||||
className="min-h-0 px-3 py-1 text-[12px]"
|
className="min-h-0 px-3 py-1 text-[12px]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSave({ company: resolvedCompany, tech: t, techPhone: phone });
|
onSave({
|
||||||
|
vendorId: selectedVendor ? String(selectedVendor.id) : "",
|
||||||
|
company: resolvedCompany,
|
||||||
|
tech: t,
|
||||||
|
techPhone: phone,
|
||||||
|
});
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Droplets,
|
Droplets,
|
||||||
Filter,
|
Filter,
|
||||||
|
Flame,
|
||||||
Gauge,
|
Gauge,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
Lock,
|
Lock,
|
||||||
|
|
@ -17,25 +18,111 @@ import {
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export const PM_TYPE_ICONS: Record<string, LucideIcon> = {
|
/**
|
||||||
"Scrubber Pit": Filter,
|
* Frontend-only icon keys for service / PM types.
|
||||||
"Backflow Preventers": Shield,
|
* Display mapping only — never sent to or stored by the API.
|
||||||
"Domestic Backflow": ShieldCheck,
|
*/
|
||||||
"Tank / Water Heater": Thermometer,
|
export enum ServiceIcon {
|
||||||
"Pressure Reducing Valve": Gauge,
|
ScrubberPit = "scrubber-pit",
|
||||||
"Pumping Systems": Waves,
|
BackflowPreventers = "backflow-preventers",
|
||||||
"HVAC Inspection": Wind,
|
DomesticBackflow = "domestic-backflow",
|
||||||
Electrical: Zap,
|
TankWaterHeater = "tank-water-heater",
|
||||||
Plumbing: Wrench,
|
PressureReducingValve = "pressure-reducing-valve",
|
||||||
"Door / Hardware": Lock,
|
PumpingSystems = "pumping-systems",
|
||||||
"Floor Drain Backing Up": Droplets,
|
HvacInspection = "hvac-inspection",
|
||||||
"Parking Lot Lighting": Lightbulb,
|
Electrical = "electrical",
|
||||||
"Mens Restroom Camera Line": Video,
|
Plumbing = "plumbing",
|
||||||
"Assess Window Leaks": AlertTriangle,
|
DoorHardware = "door-hardware",
|
||||||
|
FloorDrainBackingUp = "floor-drain-backing-up",
|
||||||
|
ParkingLotLighting = "parking-lot-lighting",
|
||||||
|
MensRestroomCameraLine = "mens-restroom-camera-line",
|
||||||
|
AssessWindowLeaks = "assess-window-leaks",
|
||||||
|
FireSuppression = "fire-suppression",
|
||||||
|
Leak = "leak",
|
||||||
|
Default = "default",
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVICE_ICON_COMPONENTS: Record<ServiceIcon, LucideIcon> = {
|
||||||
|
[ServiceIcon.ScrubberPit]: Filter,
|
||||||
|
[ServiceIcon.BackflowPreventers]: Shield,
|
||||||
|
[ServiceIcon.DomesticBackflow]: ShieldCheck,
|
||||||
|
[ServiceIcon.TankWaterHeater]: Thermometer,
|
||||||
|
[ServiceIcon.PressureReducingValve]: Gauge,
|
||||||
|
[ServiceIcon.PumpingSystems]: Waves,
|
||||||
|
[ServiceIcon.HvacInspection]: Wind,
|
||||||
|
[ServiceIcon.Electrical]: Zap,
|
||||||
|
[ServiceIcon.Plumbing]: Wrench,
|
||||||
|
[ServiceIcon.DoorHardware]: Lock,
|
||||||
|
[ServiceIcon.FloorDrainBackingUp]: Droplets,
|
||||||
|
[ServiceIcon.ParkingLotLighting]: Lightbulb,
|
||||||
|
[ServiceIcon.MensRestroomCameraLine]: Video,
|
||||||
|
[ServiceIcon.AssessWindowLeaks]: AlertTriangle,
|
||||||
|
[ServiceIcon.FireSuppression]: Flame,
|
||||||
|
[ServiceIcon.Leak]: Droplets,
|
||||||
|
[ServiceIcon.Default]: Wrench,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Exact label match (case-insensitive) — design PM types + common API Problems. */
|
||||||
|
const SERVICE_LABEL_TO_ICON: Record<string, ServiceIcon> = {
|
||||||
|
"scrubber pit": ServiceIcon.ScrubberPit,
|
||||||
|
"backflow preventers": ServiceIcon.BackflowPreventers,
|
||||||
|
"domestic backflow": ServiceIcon.DomesticBackflow,
|
||||||
|
"tank / water heater": ServiceIcon.TankWaterHeater,
|
||||||
|
"pressure reducing valve": ServiceIcon.PressureReducingValve,
|
||||||
|
"pumping systems": ServiceIcon.PumpingSystems,
|
||||||
|
"hvac inspection": ServiceIcon.HvacInspection,
|
||||||
|
electrical: ServiceIcon.Electrical,
|
||||||
|
plumbing: ServiceIcon.Plumbing,
|
||||||
|
"door / hardware": ServiceIcon.DoorHardware,
|
||||||
|
"floor drain backing up": ServiceIcon.FloorDrainBackingUp,
|
||||||
|
"parking lot lighting": ServiceIcon.ParkingLotLighting,
|
||||||
|
"mens restroom camera line": ServiceIcon.MensRestroomCameraLine,
|
||||||
|
"assess window leaks": ServiceIcon.AssessWindowLeaks,
|
||||||
|
"fire suppression system": ServiceIcon.FireSuppression,
|
||||||
|
leak: ServiceIcon.Leak,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Keyword heuristics for API Problem labels not in the exact map. */
|
||||||
|
const SERVICE_KEYWORD_RULES: { test: RegExp; icon: ServiceIcon }[] = [
|
||||||
|
{ test: /\b(fire|suppression)\b/i, icon: ServiceIcon.FireSuppression },
|
||||||
|
{ test: /\b(camera|video)\b/i, icon: ServiceIcon.MensRestroomCameraLine },
|
||||||
|
{ test: /\b(window)\b/i, icon: ServiceIcon.AssessWindowLeaks },
|
||||||
|
{ test: /\b(leak|drain)\b/i, icon: ServiceIcon.Leak },
|
||||||
|
{ test: /\b(backflow)\b/i, icon: ServiceIcon.BackflowPreventers },
|
||||||
|
{ test: /\b(hvac|air)\b/i, icon: ServiceIcon.HvacInspection },
|
||||||
|
{ test: /\b(electric)/i, icon: ServiceIcon.Electrical },
|
||||||
|
{ test: /\b(plumb)/i, icon: ServiceIcon.Plumbing },
|
||||||
|
{ test: /\b(door|hardware|lock)\b/i, icon: ServiceIcon.DoorHardware },
|
||||||
|
{ test: /\b(light)/i, icon: ServiceIcon.ParkingLotLighting },
|
||||||
|
{ test: /\b(pump)/i, icon: ServiceIcon.PumpingSystems },
|
||||||
|
{ test: /\b(tank|heater|thermo)/i, icon: ServiceIcon.TankWaterHeater },
|
||||||
|
{ test: /\b(valve|pressure|gauge)\b/i, icon: ServiceIcon.PressureReducingValve },
|
||||||
|
{ test: /\b(scrubber|filter)\b/i, icon: ServiceIcon.ScrubberPit },
|
||||||
|
{ test: /\b(broken|damaged|fault)\b/i, icon: ServiceIcon.AssessWindowLeaks },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function resolveServiceIcon(serviceLabel: string): ServiceIcon {
|
||||||
|
const key = serviceLabel.trim().toLowerCase();
|
||||||
|
if (!key) {
|
||||||
|
return ServiceIcon.Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exact = SERVICE_LABEL_TO_ICON[key];
|
||||||
|
if (exact) {
|
||||||
|
return exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rule of SERVICE_KEYWORD_RULES) {
|
||||||
|
if (rule.test.test(key)) {
|
||||||
|
return rule.icon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ServiceIcon.Default;
|
||||||
|
}
|
||||||
|
|
||||||
export function getPmTypeIcon(pmType: string): LucideIcon {
|
export function getPmTypeIcon(pmType: string): LucideIcon {
|
||||||
return PM_TYPE_ICONS[pmType] ?? Wrench;
|
return SERVICE_ICON_COMPONENTS[resolveServiceIcon(pmType)];
|
||||||
}
|
}
|
||||||
|
|
||||||
type PmTypeIconProps = ComponentProps<LucideIcon> & { pmType: string };
|
type PmTypeIconProps = ComponentProps<LucideIcon> & { pmType: string };
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { toast, type ToastOptions } from "react-toastify";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { fmtDate, fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
import { RescheduleToast } from "./reschedule-toast";
|
||||||
|
|
||||||
|
const TOAST_DURATION_MS = 6000;
|
||||||
|
|
||||||
|
const TOAST_OPTIONS: ToastOptions = {
|
||||||
|
position: "top-center",
|
||||||
|
autoClose: TOAST_DURATION_MS,
|
||||||
|
hideProgressBar: true,
|
||||||
|
closeButton: false,
|
||||||
|
icon: false,
|
||||||
|
className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
|
||||||
|
};
|
||||||
|
|
||||||
|
type NotifyNavigate = {
|
||||||
|
onNavigate: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function showRescheduleToast(message: string, onNavigate: () => void) {
|
||||||
|
const toastId = toast(
|
||||||
|
createElement(RescheduleToast, {
|
||||||
|
message,
|
||||||
|
linkLabel: "Click to view it.",
|
||||||
|
onNavigate: () => {
|
||||||
|
toast.dismiss(toastId);
|
||||||
|
onNavigate();
|
||||||
|
},
|
||||||
|
onDismiss: () => toast.dismiss(toastId),
|
||||||
|
}),
|
||||||
|
TOAST_OPTIONS,
|
||||||
|
);
|
||||||
|
return toastId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function weekMoveNotify(weekStart: string, { onNavigate }: NotifyNavigate) {
|
||||||
|
return showRescheduleToast(
|
||||||
|
`Work order moved to Unscheduled · ${fmtWeekOf(weekStart)}.`,
|
||||||
|
onNavigate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rescheduleNotify(dateIso: string, { onNavigate }: NotifyNavigate) {
|
||||||
|
return showRescheduleToast(`Work order rescheduled to ${fmtDate(dateIso)}.`, onNavigate);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Calendar as CalendarIcon, X } from "lucide-react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
const TOAST_DURATION_MS = 6000;
|
||||||
|
|
||||||
|
type RescheduleToastProps = {
|
||||||
|
message: string;
|
||||||
|
linkLabel: string;
|
||||||
|
onNavigate: () => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RescheduleToast({
|
||||||
|
message,
|
||||||
|
linkLabel,
|
||||||
|
onNavigate,
|
||||||
|
onDismiss,
|
||||||
|
}: RescheduleToastProps) {
|
||||||
|
const [w, setW] = useState(100);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setW(0), 30);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 360,
|
||||||
|
background: "var(--card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: "0 8px 24px -8px rgba(38,34,98,.25)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2.5 p-3">
|
||||||
|
<CalendarIcon size={16} className="mt-0.5 shrink-0" style={{ color: "var(--primary)" }} />
|
||||||
|
<button type="button" onClick={onNavigate} className="flex-1 text-left hover:opacity-80">
|
||||||
|
<Text as="p" style={{ fontSize: 13 }}>
|
||||||
|
{message} <span style={{ color: "var(--primary)", fontWeight: 500 }}>{linkLabel}</span>
|
||||||
|
</Text>
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onDismiss} title="Dismiss" className="shrink-0">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 3,
|
||||||
|
width: `${w}%`,
|
||||||
|
background: "var(--primary)",
|
||||||
|
transition: `width ${TOAST_DURATION_MS}ms linear`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
export type SitePlaceLookup = {
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildSitePlaceLookups(sites: LocationOption[]): {
|
||||||
|
byId: Map<string, SitePlaceLookup>;
|
||||||
|
byCode: Map<string, SitePlaceLookup>;
|
||||||
|
} {
|
||||||
|
const byId = new Map<string, SitePlaceLookup>();
|
||||||
|
const byCode = new Map<string, SitePlaceLookup>();
|
||||||
|
for (const site of sites) {
|
||||||
|
const place: SitePlaceLookup = {
|
||||||
|
city: site.city ?? "",
|
||||||
|
state: site.state ?? "",
|
||||||
|
};
|
||||||
|
if (!place.city && !place.state) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
byId.set(String(site.id), place);
|
||||||
|
const code = site.siteCode?.trim();
|
||||||
|
if (code) {
|
||||||
|
byCode.set(code.toUpperCase(), place);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { byId, byCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSitePlace(
|
||||||
|
locationId: string | number | undefined,
|
||||||
|
siteCode: string | undefined,
|
||||||
|
lookups: { byId: Map<string, SitePlaceLookup>; byCode: Map<string, SitePlaceLookup> },
|
||||||
|
): SitePlaceLookup | undefined {
|
||||||
|
if (locationId !== undefined && locationId !== "") {
|
||||||
|
const byId = lookups.byId.get(String(locationId));
|
||||||
|
if (byId) {
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const code = siteCode?.trim();
|
||||||
|
if (code) {
|
||||||
|
return lookups.byCode.get(code.toUpperCase());
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
/** Minimal row shape needed by StatusCell while a new work order is being drafted inline. */
|
||||||
|
export function toInlineDraftRow(draft: WorkOrderTableDraft) {
|
||||||
|
return {
|
||||||
|
id: "draft",
|
||||||
|
woNumber: draft.woNumber,
|
||||||
|
status: draft.status,
|
||||||
|
isPastDue: false,
|
||||||
|
type: draft.type,
|
||||||
|
rescheduleCount: 0,
|
||||||
|
carriedOver: 0,
|
||||||
|
docStatus: "" as const,
|
||||||
|
mediaCount: 0,
|
||||||
|
site: draft.site,
|
||||||
|
location: draft.location,
|
||||||
|
dispatcherId: draft.dispatcherId,
|
||||||
|
scheduledOn: draft.scheduledOn,
|
||||||
|
dueDate: draft.dueDate,
|
||||||
|
pm: draft.pm,
|
||||||
|
serviceNotes: draft.serviceNotes,
|
||||||
|
extraServices: draft.extraServices,
|
||||||
|
company: draft.company,
|
||||||
|
tech: draft.tech,
|
||||||
|
targetWeek: draft.targetWeek,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import type {
|
||||||
|
WorkOrderTablePatch,
|
||||||
|
WorkOrderTableRow,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||||
|
import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify-cross-week";
|
||||||
|
import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||||
|
import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify";
|
||||||
|
import type { WoTableRowHandlers } from "./wo-table-row";
|
||||||
|
|
||||||
|
type UseWoTableRowHandlersOptions = {
|
||||||
|
tableData: ReturnTypeOfUseWorkOrdersTableData;
|
||||||
|
weekMonday: string;
|
||||||
|
onWeekChange: (mondayIso: string) => void;
|
||||||
|
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
||||||
|
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||||
|
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||||
|
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
|
||||||
|
onScheduleHighlight: (id: string | number | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useWoTableRowHandlers({
|
||||||
|
tableData,
|
||||||
|
weekMonday,
|
||||||
|
onWeekChange,
|
||||||
|
onOpenSlideOver,
|
||||||
|
onOpenCompDoc,
|
||||||
|
onOpenMedia,
|
||||||
|
onPatch,
|
||||||
|
onScheduleHighlight,
|
||||||
|
}: UseWoTableRowHandlersOptions): WoTableRowHandlers {
|
||||||
|
const {
|
||||||
|
dragState: dragStateRef,
|
||||||
|
setDragViz,
|
||||||
|
reorderRows,
|
||||||
|
endDrag,
|
||||||
|
setUnschedOpen,
|
||||||
|
triggerFlash,
|
||||||
|
} = tableData;
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
onRowClick: (row: WorkOrderTableRow) => {
|
||||||
|
tableData.setSelectedId(row.id);
|
||||||
|
onOpenSlideOver(row, false);
|
||||||
|
},
|
||||||
|
onView: (row: WorkOrderTableRow) => {
|
||||||
|
tableData.setSelectedId(row.id);
|
||||||
|
onOpenSlideOver(row, false);
|
||||||
|
},
|
||||||
|
onEdit: (row: WorkOrderTableRow) => {
|
||||||
|
tableData.setSelectedId(row.id);
|
||||||
|
onOpenSlideOver(row, true);
|
||||||
|
},
|
||||||
|
onOpenCompDoc: onOpenCompDoc
|
||||||
|
? (row: WorkOrderTableRow) => {
|
||||||
|
tableData.setSelectedId(row.id);
|
||||||
|
onOpenCompDoc(row);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onOpenMedia: (row: WorkOrderTableRow) => {
|
||||||
|
tableData.setSelectedId(row.id);
|
||||||
|
onOpenMedia(row);
|
||||||
|
},
|
||||||
|
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => {
|
||||||
|
onPatch(id, patch);
|
||||||
|
},
|
||||||
|
onScheduleDate: (id: string | number, date: string) => {
|
||||||
|
onPatch(id, applySchedulePatch(date, false));
|
||||||
|
if (shouldNotifyCrossWeek(date, weekMonday)) {
|
||||||
|
const targetMonday = mondayOf(date);
|
||||||
|
rescheduleNotify(date, {
|
||||||
|
onNavigate: () => {
|
||||||
|
onWeekChange(targetMonday);
|
||||||
|
setTimeout(() => triggerFlash(id), 180);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onScheduleWeek: (id: string | number, week: string) => {
|
||||||
|
onPatch(id, {
|
||||||
|
scheduledOn: "",
|
||||||
|
targetWeek: week,
|
||||||
|
scheduleWeekOnly: true,
|
||||||
|
dayGroup: "",
|
||||||
|
dayLabel: "",
|
||||||
|
});
|
||||||
|
setUnschedOpen(true);
|
||||||
|
triggerFlash(id);
|
||||||
|
weekMoveNotify(week, {
|
||||||
|
onNavigate: () => {
|
||||||
|
setUnschedOpen(true);
|
||||||
|
setTimeout(() => triggerFlash(id), 180);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onDuplicateFound: (existing: WorkOrderTableRow) => {
|
||||||
|
tableData.setSelectedId(existing.id);
|
||||||
|
onOpenSlideOver(existing, false);
|
||||||
|
},
|
||||||
|
onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false),
|
||||||
|
onPastDueAttempt: (id: string | number) => onScheduleHighlight(id),
|
||||||
|
onReorder: reorderRows,
|
||||||
|
onDragStart: (id: string | number) => {
|
||||||
|
dragStateRef.current = { dragId: id, overId: null };
|
||||||
|
setDragViz({ dragId: id, overId: null });
|
||||||
|
},
|
||||||
|
onDragOver: (id: string | number) => {
|
||||||
|
const did = dragStateRef.current.dragId;
|
||||||
|
if (did && did !== id && dragStateRef.current.overId !== id) {
|
||||||
|
dragStateRef.current.overId = id;
|
||||||
|
setDragViz((v) => ({ ...v, overId: id }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDrop: () => {
|
||||||
|
const { dragId, overId } = dragStateRef.current;
|
||||||
|
if (dragId && overId) {
|
||||||
|
reorderRows(dragId, overId);
|
||||||
|
}
|
||||||
|
endDrag();
|
||||||
|
},
|
||||||
|
onDragEnd: endDrag,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
tableData,
|
||||||
|
dragStateRef,
|
||||||
|
setDragViz,
|
||||||
|
reorderRows,
|
||||||
|
endDrag,
|
||||||
|
setUnschedOpen,
|
||||||
|
triggerFlash,
|
||||||
|
weekMonday,
|
||||||
|
onWeekChange,
|
||||||
|
onOpenSlideOver,
|
||||||
|
onOpenCompDoc,
|
||||||
|
onOpenMedia,
|
||||||
|
onPatch,
|
||||||
|
onScheduleHighlight,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { Fragment, type ReactNode } from "react";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||||
|
import { WoTableSectionHeader } from "./wo-table-section-header";
|
||||||
|
|
||||||
|
type WoTableBodyProps = {
|
||||||
|
tableData: ReturnTypeOfUseWorkOrdersTableData;
|
||||||
|
renderRow: (row: WorkOrderTableRow, forceScheduleHighlight?: boolean) => ReactNode;
|
||||||
|
renderInlineRow: (dayKey: string) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBodyProps) {
|
||||||
|
if (tableData.advActive) {
|
||||||
|
return <tbody>{tableData.advResults.map((row) => renderRow(row))}</tbody>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showUnscheduled =
|
||||||
|
tableData.unscheduled.length > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tbody>
|
||||||
|
{showUnscheduled && (
|
||||||
|
<>
|
||||||
|
<WoTableSectionHeader
|
||||||
|
variant="unscheduled"
|
||||||
|
label="Unscheduled"
|
||||||
|
count={tableData.unscheduled.length}
|
||||||
|
collapsed={!tableData.unschedOpen}
|
||||||
|
onToggleCollapse={() => tableData.setUnschedOpen((v) => !v)}
|
||||||
|
onAddWo={() => tableData.startDayDraft(UNSCHEDULED_DAY_KEY)}
|
||||||
|
/>
|
||||||
|
{tableData.unschedOpen && (
|
||||||
|
<>
|
||||||
|
{renderInlineRow(UNSCHEDULED_DAY_KEY)}
|
||||||
|
{tableData.unscheduled.map((row) => renderRow(row, true))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tableData.displayGroups.map((group) => (
|
||||||
|
<Fragment key={group.key}>
|
||||||
|
<WoTableSectionHeader
|
||||||
|
variant="day"
|
||||||
|
label={group.label}
|
||||||
|
count={group.items.length}
|
||||||
|
isToday={group.key === tableData.todayKey}
|
||||||
|
onAddWo={() => tableData.startDayDraft(group.key, group.date)}
|
||||||
|
/>
|
||||||
|
{renderInlineRow(group.key)}
|
||||||
|
{group.items.map((row) => renderRow(row))}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { CircularProgress } from "@mui/material";
|
||||||
|
import { Check, X } from "lucide-react";
|
||||||
|
|
||||||
|
type WoTableInlineRowActionsProps = {
|
||||||
|
canSave: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WoTableInlineRowActions({
|
||||||
|
canSave,
|
||||||
|
saving,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: WoTableInlineRowActionsProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!canSave}
|
||||||
|
title={saving ? "Saving…" : canSave ? "Save" : "Schedule On is required"}
|
||||||
|
aria-label={saving ? "Saving work order" : "Save work order"}
|
||||||
|
className="rounded p-1 transition-colors disabled:opacity-30"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (canSave) {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-primary-tint)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? <CircularProgress size={13} color="inherit" /> : <Check size={13} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={saving}
|
||||||
|
title="Cancel"
|
||||||
|
aria-label="Cancel draft"
|
||||||
|
className="rounded p-1 transition-colors disabled:opacity-30"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-bg-muted)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
import type {
|
||||||
|
LocationOption,
|
||||||
|
VendorDropdownItem,
|
||||||
|
WorkOrderUser,
|
||||||
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||||
|
import { ApptTimeCell } from "./cells/appt-time-cell";
|
||||||
|
import { DateCell } from "./cells/date-cell";
|
||||||
|
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
||||||
|
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||||
|
import { SiteCell } from "./cells/site-cell";
|
||||||
|
import { StatusCell } from "./cells/status-cell";
|
||||||
|
import { TypeCell } from "./cells/type-cell";
|
||||||
|
import { VendorCell } from "./cells/vendor-cell";
|
||||||
|
import { toInlineDraftRow } from "./to-inline-draft-row";
|
||||||
|
|
||||||
|
type WoTableInlineRowCellsProps = {
|
||||||
|
draft: WorkOrderTableDraft;
|
||||||
|
weekDefault: boolean;
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
sites: LocationOption[];
|
||||||
|
sitePlace?: { city: string; state: string };
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
pmTypes: string[];
|
||||||
|
allRows: { id: string | number; woNumber: string }[];
|
||||||
|
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WoTableInlineRowCells({
|
||||||
|
draft,
|
||||||
|
weekDefault,
|
||||||
|
users,
|
||||||
|
sites,
|
||||||
|
sitePlace,
|
||||||
|
vendors,
|
||||||
|
pmTypes,
|
||||||
|
allRows,
|
||||||
|
onPatch,
|
||||||
|
}: WoTableInlineRowCellsProps) {
|
||||||
|
const draftRow = toInlineDraftRow(draft);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<SiteCell
|
||||||
|
value={draft.site}
|
||||||
|
location={draft.location}
|
||||||
|
locationId={draft.locationId}
|
||||||
|
city={sitePlace?.city}
|
||||||
|
state={sitePlace?.state}
|
||||||
|
pocName={draft.pocName}
|
||||||
|
pocPhone={draft.pocPhone}
|
||||||
|
pocNotes={draft.pocNotes}
|
||||||
|
sites={sites}
|
||||||
|
onSave={(p) =>
|
||||||
|
onPatch({
|
||||||
|
site: p.site,
|
||||||
|
location: p.location,
|
||||||
|
locationId: p.locationId,
|
||||||
|
pocName: p.pocName,
|
||||||
|
pocPhone: p.pocPhone,
|
||||||
|
pocNotes: p.pocNotes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style={{ padding: "0 14px" }}>
|
||||||
|
<EditableWONumberCell
|
||||||
|
value={draft.woNumber}
|
||||||
|
rescheduleCount={0}
|
||||||
|
currentId="draft"
|
||||||
|
allOrders={allRows as never[]}
|
||||||
|
onSave={(v) => onPatch({ woNumber: v })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<TypeCell value={draft.type} onSave={(t) => onPatch({ type: t })} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<AssignedToCell
|
||||||
|
dispatcherId={draft.dispatcherId}
|
||||||
|
users={users}
|
||||||
|
highlight={!draft.dispatcherId}
|
||||||
|
onSave={(id) => {
|
||||||
|
onPatch({ dispatcherId: id, status: draft.status || "Incomplete" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<DateCell
|
||||||
|
value={draft.scheduledOn}
|
||||||
|
allowWeekMode
|
||||||
|
targetWeek={draft.targetWeek}
|
||||||
|
defaultMode={weekDefault ? "week" : "date"}
|
||||||
|
highlight={!draft.scheduledOn && !draft.targetWeek}
|
||||||
|
onSave={(date) => onPatch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
|
||||||
|
onSaveWeek={(week) =>
|
||||||
|
onPatch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<DateCell value={draft.dueDate} onSave={(date) => onPatch({ dueDate: date })} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<PMTypeCell
|
||||||
|
value={draft.pm}
|
||||||
|
pmTypes={pmTypes}
|
||||||
|
serviceNotes={draft.serviceNotes}
|
||||||
|
extraServices={draft.extraServices}
|
||||||
|
woNumber={draft.woNumber}
|
||||||
|
onSave={(pm) => onPatch({ pm })}
|
||||||
|
onSaveDetails={(d) =>
|
||||||
|
onPatch({
|
||||||
|
pm: d.pm,
|
||||||
|
extraServices: d.extraServices,
|
||||||
|
serviceNotes: d.serviceNotes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<VendorCell
|
||||||
|
company={draft.company}
|
||||||
|
tech={draft.tech}
|
||||||
|
techPhone={draft.techPhone}
|
||||||
|
vendors={vendors}
|
||||||
|
onSave={(p) =>
|
||||||
|
onPatch({
|
||||||
|
vendorId: p.vendorId,
|
||||||
|
company: p.company,
|
||||||
|
tech: p.tech,
|
||||||
|
techPhone: p.techPhone,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<ApptTimeCell
|
||||||
|
apptTime={
|
||||||
|
draft.apptStart ? `${draft.apptStart}${draft.apptEnd ? ` – ${draft.apptEnd}` : ""}` : ""
|
||||||
|
}
|
||||||
|
onSave={(v) => {
|
||||||
|
const [start, end] = v.split(" – ");
|
||||||
|
onPatch({ apptStart: start ?? "", apptEnd: end ?? "" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<StatusCell
|
||||||
|
wo={draftRow as never}
|
||||||
|
onChangeStatus={(status) => onPatch({ status })}
|
||||||
|
onOpenSlideOver={() => {}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style={{ padding: "0 14px" }}>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Check, Flag, X } from "lucide-react";
|
import { Flag } from "lucide-react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
LocationOption,
|
LocationOption,
|
||||||
|
|
@ -10,25 +10,9 @@ import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-
|
||||||
|
|
||||||
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
|
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
import { AssignedToCell } from "./cells/assigned-to-cell";
|
|
||||||
|
|
||||||
import { ApptTimeCell } from "./cells/appt-time-cell";
|
|
||||||
|
|
||||||
import { DateCell } from "./cells/date-cell";
|
|
||||||
|
|
||||||
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
|
||||||
|
|
||||||
import { PMTypeCell } from "./cells/pm-type-cell";
|
|
||||||
|
|
||||||
import { SiteCell } from "./cells/site-cell";
|
|
||||||
|
|
||||||
import { StatusCell } from "./cells/status-cell";
|
|
||||||
|
|
||||||
import { TypeCell } from "./cells/type-cell";
|
|
||||||
|
|
||||||
import { VendorCell } from "./cells/vendor-cell";
|
|
||||||
|
|
||||||
import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
|
import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
|
||||||
|
import { WoTableInlineRowCells } from "./wo-table-inline-row-cells";
|
||||||
|
import { WoTableInlineRowActions } from "./wo-table-inline-row-actions";
|
||||||
|
|
||||||
type WoTableInlineRowProps = {
|
type WoTableInlineRowProps = {
|
||||||
draft: WorkOrderTableDraft;
|
draft: WorkOrderTableDraft;
|
||||||
|
|
@ -39,12 +23,16 @@ type WoTableInlineRowProps = {
|
||||||
|
|
||||||
sites: LocationOption[];
|
sites: LocationOption[];
|
||||||
|
|
||||||
|
sitePlace?: { city: string; state: string };
|
||||||
|
|
||||||
vendors: VendorDropdownItem[];
|
vendors: VendorDropdownItem[];
|
||||||
|
|
||||||
pmTypes: string[];
|
pmTypes: string[];
|
||||||
|
|
||||||
allRows: { id: string | number; woNumber: string }[];
|
allRows: { id: string | number; woNumber: string }[];
|
||||||
|
|
||||||
|
saving?: boolean;
|
||||||
|
|
||||||
onChange: (draft: WorkOrderTableDraft) => void;
|
onChange: (draft: WorkOrderTableDraft) => void;
|
||||||
|
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
|
|
@ -61,12 +49,16 @@ export function WoTableInlineRow({
|
||||||
|
|
||||||
sites,
|
sites,
|
||||||
|
|
||||||
|
sitePlace,
|
||||||
|
|
||||||
vendors,
|
vendors,
|
||||||
|
|
||||||
pmTypes,
|
pmTypes,
|
||||||
|
|
||||||
allRows,
|
allRows,
|
||||||
|
|
||||||
|
saving = false,
|
||||||
|
|
||||||
onChange,
|
onChange,
|
||||||
|
|
||||||
onSave,
|
onSave,
|
||||||
|
|
@ -75,50 +67,14 @@ export function WoTableInlineRow({
|
||||||
}: WoTableInlineRowProps) {
|
}: WoTableInlineRowProps) {
|
||||||
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
|
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
|
||||||
|
|
||||||
const canSave = weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn;
|
const canSave =
|
||||||
|
!saving && (weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn);
|
||||||
|
|
||||||
const patch = (p: Partial<WorkOrderTableDraft>) => onChange({ ...draft, ...p });
|
const patch = (p: Partial<WorkOrderTableDraft>) => onChange({ ...draft, ...p });
|
||||||
|
|
||||||
const draftRow = {
|
|
||||||
id: "draft",
|
|
||||||
|
|
||||||
woNumber: draft.woNumber,
|
|
||||||
|
|
||||||
status: draft.status,
|
|
||||||
|
|
||||||
isPastDue: false,
|
|
||||||
|
|
||||||
type: draft.type,
|
|
||||||
|
|
||||||
rescheduleCount: 0,
|
|
||||||
|
|
||||||
carriedOver: 0,
|
|
||||||
|
|
||||||
docStatus: "" as const,
|
|
||||||
|
|
||||||
mediaCount: 0,
|
|
||||||
|
|
||||||
site: draft.site,
|
|
||||||
|
|
||||||
location: draft.location,
|
|
||||||
|
|
||||||
dispatcherId: draft.dispatcherId,
|
|
||||||
|
|
||||||
scheduledOn: draft.scheduledOn,
|
|
||||||
|
|
||||||
dueDate: draft.dueDate,
|
|
||||||
|
|
||||||
pm: draft.pm,
|
|
||||||
|
|
||||||
company: draft.company,
|
|
||||||
|
|
||||||
tech: draft.tech,
|
|
||||||
|
|
||||||
targetWeek: draft.targetWeek,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
|
aria-busy={saving || undefined}
|
||||||
style={{
|
style={{
|
||||||
height: WO_TABLE_ROW_HEIGHT,
|
height: WO_TABLE_ROW_HEIGHT,
|
||||||
|
|
||||||
|
|
@ -127,6 +83,8 @@ export function WoTableInlineRow({
|
||||||
borderLeft: "2px solid var(--color-primary)",
|
borderLeft: "2px solid var(--color-primary)",
|
||||||
|
|
||||||
background: "var(--color-primary-tint)",
|
background: "var(--color-primary-tint)",
|
||||||
|
opacity: saving ? 0.7 : 1,
|
||||||
|
pointerEvents: saving ? "none" : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td style={{ padding: "0 0 0 6px" }} />
|
<td style={{ padding: "0 0 0 6px" }} />
|
||||||
|
|
@ -135,153 +93,25 @@ export function WoTableInlineRow({
|
||||||
<Flag size={14} strokeWidth={1.75} style={{ color: "var(--color-border)" }} />
|
<Flag size={14} strokeWidth={1.75} style={{ color: "var(--color-border)" }} />
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
<WoTableInlineRowCells
|
||||||
<SiteCell
|
draft={draft}
|
||||||
value={draft.site}
|
weekDefault={weekDefault}
|
||||||
location={draft.location}
|
users={users}
|
||||||
locationId={draft.locationId}
|
sites={sites}
|
||||||
pocName={draft.pocName}
|
sitePlace={sitePlace}
|
||||||
pocPhone={draft.pocPhone}
|
vendors={vendors}
|
||||||
pocNotes={draft.pocNotes}
|
pmTypes={pmTypes}
|
||||||
sites={sites}
|
allRows={allRows}
|
||||||
onSave={(p) =>
|
onPatch={patch}
|
||||||
patch({
|
/>
|
||||||
site: p.site,
|
|
||||||
|
|
||||||
location: p.location,
|
|
||||||
|
|
||||||
locationId: p.locationId,
|
|
||||||
|
|
||||||
pocName: p.pocName,
|
|
||||||
|
|
||||||
pocPhone: p.pocPhone,
|
|
||||||
|
|
||||||
pocNotes: p.pocNotes,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td style={{ padding: "0 14px" }}>
|
|
||||||
<EditableWONumberCell
|
|
||||||
value={draft.woNumber}
|
|
||||||
rescheduleCount={0}
|
|
||||||
currentId="draft"
|
|
||||||
allOrders={allRows as never[]}
|
|
||||||
onSave={(v) => patch({ woNumber: v })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
|
||||||
<TypeCell value={draft.type} onSave={(t) => patch({ type: t })} />
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
|
||||||
<AssignedToCell
|
|
||||||
dispatcherId={draft.dispatcherId}
|
|
||||||
users={users}
|
|
||||||
highlight={!draft.dispatcherId}
|
|
||||||
onSave={(id) => {
|
|
||||||
patch({ dispatcherId: id, status: draft.status || "Incomplete" });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
|
||||||
<DateCell
|
|
||||||
value={draft.scheduledOn}
|
|
||||||
allowWeekMode
|
|
||||||
targetWeek={draft.targetWeek}
|
|
||||||
defaultMode={weekDefault ? "week" : "date"}
|
|
||||||
highlight={!draft.scheduledOn && !draft.targetWeek}
|
|
||||||
onSave={(date) => patch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
|
|
||||||
onSaveWeek={(week) =>
|
|
||||||
patch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
|
||||||
<DateCell value={draft.dueDate} onSave={(date) => patch({ dueDate: date })} />
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
|
||||||
<PMTypeCell value={draft.pm} pmTypes={pmTypes} onSave={(pm) => patch({ pm })} />
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
|
||||||
<VendorCell
|
|
||||||
company={draft.company}
|
|
||||||
tech={draft.tech}
|
|
||||||
techPhone={draft.techPhone}
|
|
||||||
vendors={vendors}
|
|
||||||
onSave={(p) => patch({ company: p.company, tech: p.tech, techPhone: p.techPhone })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
|
||||||
<ApptTimeCell
|
|
||||||
apptTime={
|
|
||||||
draft.apptStart ? `${draft.apptStart}${draft.apptEnd ? ` – ${draft.apptEnd}` : ""}` : ""
|
|
||||||
}
|
|
||||||
onSave={(v) => {
|
|
||||||
const [start, end] = v.split(" – ");
|
|
||||||
|
|
||||||
patch({ apptStart: start ?? "", apptEnd: end ?? "" });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
|
||||||
<StatusCell
|
|
||||||
wo={draftRow as never}
|
|
||||||
onChangeStatus={(status) => patch({ status })}
|
|
||||||
onOpenSlideOver={() => {}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td style={{ padding: "0 14px" }}>
|
|
||||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td style={{ padding: "0 4px" }}>
|
<td style={{ padding: "0 4px" }}>
|
||||||
<div className="flex items-center gap-0.5">
|
<WoTableInlineRowActions
|
||||||
<button
|
canSave={canSave}
|
||||||
type="button"
|
saving={saving}
|
||||||
onClick={onSave}
|
onSave={onSave}
|
||||||
disabled={!canSave}
|
onCancel={onCancel}
|
||||||
title={canSave ? "Save" : "Schedule On is required"}
|
/>
|
||||||
className="rounded p-1 transition-colors disabled:opacity-30"
|
|
||||||
style={{ color: "var(--color-primary)" }}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (canSave) {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.background =
|
|
||||||
"var(--color-primary-tint)";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check size={13} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
title="Cancel"
|
|
||||||
className="rounded p-1 transition-colors"
|
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-bg-muted)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { SearchX } from "lucide-react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
const CARD_STYLE: React.CSSProperties = {
|
||||||
|
background: "var(--card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: "0 1px 3px rgba(38,34,98,.05)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WoTableShell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden px-2 py-4">
|
||||||
|
<div className="relative h-full overflow-auto" style={CARD_STYLE}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type WoTableEmptyStateProps = {
|
||||||
|
message: string;
|
||||||
|
actionLabel: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WoTableEmptyState({ message, actionLabel, onAction }: WoTableEmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-full flex-col items-center justify-center text-center"
|
||||||
|
style={{ gap: 14, padding: 48 }}
|
||||||
|
>
|
||||||
|
<SearchX size={34} style={{ color: "var(--color-text-muted)" }} />
|
||||||
|
<Text as="p" style={{ fontSize: 14, color: "var(--muted-foreground)" }}>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
{Boolean(onAction) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAction}
|
||||||
|
className="transition-opacity hover:opacity-80"
|
||||||
|
style={{ fontSize: 13, fontWeight: 500, color: "var(--color-accent)" }}
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,174 +1,76 @@
|
||||||
import { Fragment, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { CircularProgress } from "@mui/material";
|
import { useLocationSites } from "@/domain/locations/use-cases/use-location-sites";
|
||||||
import { Text } from "@/components/ui/text";
|
|
||||||
import { SearchX } from "lucide-react";
|
|
||||||
import type {
|
import type {
|
||||||
LocationOption,
|
LocationOption,
|
||||||
VendorDropdownItem,
|
VendorDropdownItem,
|
||||||
WorkOrderUser,
|
WorkOrderUser,
|
||||||
} 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 {
|
||||||
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
|
WorkOrderTablePatch,
|
||||||
import { mapWizardDraftToFormValues } from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
WorkOrderTableRow,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
|
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
|
||||||
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
|
||||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||||
|
import { buildInlineDraftCreateInput } from "./build-inline-draft-create-input";
|
||||||
|
import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup";
|
||||||
|
import { useWoTableRowHandlers } from "./use-wo-table-row-handlers";
|
||||||
|
import { WoTableBody } from "./wo-table-body";
|
||||||
import { WoTableHead } from "./wo-table-head";
|
import { WoTableHead } from "./wo-table-head";
|
||||||
import { WoTableInlineRow } from "./wo-table-inline-row";
|
import { WoTableInlineRow } from "./wo-table-inline-row";
|
||||||
import { WoTableRow } from "./wo-table-row";
|
import { WoTableRow } from "./wo-table-row";
|
||||||
import { WoTableSectionHeader } from "./wo-table-section-header";
|
import { WoTableEmptyState, WoTableShell } from "./wo-table-shell";
|
||||||
import { WO_TABLE_MIN_WIDTH } from "./wo-table-cols";
|
import { WO_TABLE_MIN_WIDTH } from "./wo-table-cols";
|
||||||
|
|
||||||
const CARD_STYLE: React.CSSProperties = {
|
|
||||||
background: "var(--card)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: 8,
|
|
||||||
boxShadow: "0 1px 3px rgba(38,34,98,.05)",
|
|
||||||
};
|
|
||||||
|
|
||||||
type WoTableProps = {
|
type WoTableProps = {
|
||||||
tableData: ReturnTypeOfUseWorkOrdersTableData;
|
tableData: ReturnTypeOfUseWorkOrdersTableData;
|
||||||
users: WorkOrderUser[];
|
users: WorkOrderUser[];
|
||||||
sites: LocationOption[];
|
sites: LocationOption[];
|
||||||
vendors: VendorDropdownItem[];
|
vendors: VendorDropdownItem[];
|
||||||
pmTypes: string[];
|
pmTypes: string[];
|
||||||
|
weekMonday: string;
|
||||||
|
onWeekChange: (mondayIso: string) => void;
|
||||||
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
||||||
/** Omit until completion-doc slice mounts CompDocDialog. */
|
/** Omit until completion-doc slice mounts CompDocDialog. */
|
||||||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||||
onOpenMedia: (row: WorkOrderTableRow) => void;
|
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||||
onClearSearch?: () => void;
|
onClearSearch?: () => void;
|
||||||
onOpenAdvancedFilters?: () => void;
|
onOpenAdvancedFilters?: () => void;
|
||||||
|
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function WoTableShell({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-0 flex-1 overflow-hidden px-2 py-4">
|
|
||||||
<div className="relative h-full overflow-auto" style={CARD_STYLE}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WoTableEmptyState({
|
|
||||||
message,
|
|
||||||
actionLabel,
|
|
||||||
onAction,
|
|
||||||
}: {
|
|
||||||
message: string;
|
|
||||||
actionLabel: string;
|
|
||||||
onAction?: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex h-full flex-col items-center justify-center text-center"
|
|
||||||
style={{ gap: 14, padding: 48 }}
|
|
||||||
>
|
|
||||||
<SearchX size={34} style={{ color: "var(--color-text-muted)" }} />
|
|
||||||
<Text as="p" style={{ fontSize: 14, color: "var(--muted-foreground)" }}>
|
|
||||||
{message}
|
|
||||||
</Text>
|
|
||||||
{Boolean(onAction) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onAction}
|
|
||||||
className="transition-opacity hover:opacity-80"
|
|
||||||
style={{ fontSize: 13, fontWeight: 500, color: "var(--color-accent)" }}
|
|
||||||
>
|
|
||||||
{actionLabel}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WoTable({
|
export function WoTable({
|
||||||
tableData,
|
tableData,
|
||||||
users,
|
users,
|
||||||
sites,
|
sites,
|
||||||
vendors,
|
vendors,
|
||||||
pmTypes,
|
pmTypes,
|
||||||
|
weekMonday,
|
||||||
|
onWeekChange,
|
||||||
onOpenSlideOver,
|
onOpenSlideOver,
|
||||||
onOpenCompDoc,
|
onOpenCompDoc,
|
||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
onClearSearch,
|
onClearSearch,
|
||||||
onOpenAdvancedFilters,
|
onOpenAdvancedFilters,
|
||||||
|
onPatch,
|
||||||
}: WoTableProps) {
|
}: WoTableProps) {
|
||||||
const [scheduleHighlightId, setScheduleHighlightId] = useState<string | number | null>(null);
|
const [scheduleHighlightId, setScheduleHighlightId] = useState<string | number | null>(null);
|
||||||
const { patchField } = useWorkOrderTableMutations(tableData.patchRow);
|
const { data: locationSites = [] } = useLocationSites();
|
||||||
|
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
|
||||||
const createMutation = useCreateWorkOrderFromWizard(() => {
|
const createMutation = useCreateWorkOrderFromWizard(() => {
|
||||||
tableData.cancelDraft();
|
tableData.cancelDraft();
|
||||||
});
|
});
|
||||||
const { dragState: dragStateRef, setDragViz, reorderRows, endDrag, setFlag } = tableData;
|
|
||||||
|
|
||||||
const handlers = useMemo(
|
const handlers = useWoTableRowHandlers({
|
||||||
() => ({
|
tableData,
|
||||||
onRowClick: (row: WorkOrderTableRow) => {
|
weekMonday,
|
||||||
tableData.setSelectedId(row.id);
|
onWeekChange,
|
||||||
onOpenSlideOver(row, false);
|
onOpenSlideOver,
|
||||||
},
|
onOpenCompDoc,
|
||||||
onView: (row: WorkOrderTableRow) => {
|
onOpenMedia,
|
||||||
tableData.setSelectedId(row.id);
|
onPatch,
|
||||||
onOpenSlideOver(row, false);
|
onScheduleHighlight: setScheduleHighlightId,
|
||||||
},
|
});
|
||||||
onEdit: (row: WorkOrderTableRow) => {
|
|
||||||
tableData.setSelectedId(row.id);
|
|
||||||
onOpenSlideOver(row, true);
|
|
||||||
},
|
|
||||||
onOpenCompDoc: onOpenCompDoc
|
|
||||||
? (row: WorkOrderTableRow) => {
|
|
||||||
tableData.setSelectedId(row.id);
|
|
||||||
onOpenCompDoc(row);
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
onOpenMedia: (row: WorkOrderTableRow) => {
|
|
||||||
tableData.setSelectedId(row.id);
|
|
||||||
onOpenMedia(row);
|
|
||||||
},
|
|
||||||
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => {
|
|
||||||
patchField(id, patch);
|
|
||||||
},
|
|
||||||
onDuplicateFound: (existing: WorkOrderTableRow) => {
|
|
||||||
tableData.setSelectedId(existing.id);
|
|
||||||
onOpenSlideOver(existing, false);
|
|
||||||
},
|
|
||||||
onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false),
|
|
||||||
onPastDueAttempt: (id: string | number) => setScheduleHighlightId(id),
|
|
||||||
onReorder: reorderRows,
|
|
||||||
onDragStart: (id: string | number) => {
|
|
||||||
dragStateRef.current = { dragId: id, overId: null };
|
|
||||||
setDragViz({ dragId: id, overId: null });
|
|
||||||
},
|
|
||||||
onDragOver: (id: string | number) => {
|
|
||||||
const did = dragStateRef.current.dragId;
|
|
||||||
if (did && did !== id && dragStateRef.current.overId !== id) {
|
|
||||||
dragStateRef.current.overId = id;
|
|
||||||
setDragViz((v) => ({ ...v, overId: id }));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDrop: () => {
|
|
||||||
const { dragId, overId } = dragStateRef.current;
|
|
||||||
if (dragId && overId) {
|
|
||||||
reorderRows(dragId, overId);
|
|
||||||
}
|
|
||||||
endDrag();
|
|
||||||
},
|
|
||||||
onDragEnd: endDrag,
|
|
||||||
setFlag,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
tableData,
|
|
||||||
dragStateRef,
|
|
||||||
setDragViz,
|
|
||||||
reorderRows,
|
|
||||||
endDrag,
|
|
||||||
setFlag,
|
|
||||||
onOpenSlideOver,
|
|
||||||
onOpenCompDoc,
|
|
||||||
onOpenMedia,
|
|
||||||
patchField,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
|
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
|
||||||
<WoTableRow
|
<WoTableRow
|
||||||
|
|
@ -177,11 +79,11 @@ export function WoTable({
|
||||||
allRows={tableData.rows}
|
allRows={tableData.rows}
|
||||||
users={users}
|
users={users}
|
||||||
sites={sites}
|
sites={sites}
|
||||||
|
sitePlace={resolveSitePlace(row.locationId, row.site, sitePlaceLookups)}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
pmTypes={pmTypes}
|
pmTypes={pmTypes}
|
||||||
search={tableData.search}
|
search={tableData.search}
|
||||||
forceScheduleHighlight={forceScheduleHighlight || scheduleHighlightId === row.id}
|
forceScheduleHighlight={forceScheduleHighlight || scheduleHighlightId === row.id}
|
||||||
flagColor={tableData.flags[String(row.id)]}
|
|
||||||
isSelected={tableData.selectedId === row.id}
|
isSelected={tableData.selectedId === row.id}
|
||||||
isFlashing={tableData.flashId === row.id}
|
isFlashing={tableData.flashId === row.id}
|
||||||
dragViz={tableData.dragViz}
|
dragViz={tableData.dragViz}
|
||||||
|
|
@ -189,40 +91,34 @@ export function WoTable({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSaveDraft = () => {
|
const renderInlineRow = (dayKey: string) =>
|
||||||
const d = tableData.draft;
|
tableData.draftDayKey === dayKey && (
|
||||||
const assignee = users.find((u) => String(u.id) === d.dispatcherId);
|
<WoTableInlineRow
|
||||||
const formValues = mapWizardDraftToFormValues({
|
draft={tableData.draft}
|
||||||
locationId: String(d.locationId || ""),
|
draftDayKey={tableData.draftDayKey}
|
||||||
locationName: d.location || d.site,
|
users={users}
|
||||||
woNumber: d.woNumber,
|
sites={sites}
|
||||||
type: d.type,
|
sitePlace={resolveSitePlace(
|
||||||
assigneeId: d.dispatcherId,
|
tableData.draft.locationId,
|
||||||
assigneeName: assignee?.name ?? "",
|
tableData.draft.site,
|
||||||
scheduledOn: d.scheduledOn,
|
sitePlaceLookups,
|
||||||
dueDate: d.dueDate,
|
)}
|
||||||
pm: d.pm,
|
vendors={vendors}
|
||||||
vendorId: "",
|
pmTypes={pmTypes}
|
||||||
vendorName: d.company,
|
allRows={tableData.rows}
|
||||||
techPhone: d.techPhone,
|
saving={createMutation.isPending}
|
||||||
vendorNotes: "",
|
onChange={tableData.setDraft}
|
||||||
apptStart: d.apptStart,
|
onSave={() =>
|
||||||
apptEnd: d.apptEnd,
|
createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites))
|
||||||
status: d.status || "Incomplete",
|
}
|
||||||
pocName: d.pocName,
|
onCancel={tableData.cancelDraft}
|
||||||
pocPhone: d.pocPhone,
|
/>
|
||||||
pocNotes: d.pocNotes,
|
);
|
||||||
scheduleWeekOnly: d.scheduleWeekOnly,
|
|
||||||
});
|
|
||||||
createMutation.mutate(formValues);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (tableData.isLoading) {
|
if (tableData.isLoading) {
|
||||||
return (
|
return (
|
||||||
<WoTableShell>
|
<WoTableShell>
|
||||||
<div className="flex h-full items-center justify-center p-12">
|
<WoTableSkeleton weekMonday={weekMonday} />
|
||||||
<CircularProgress />
|
|
||||||
</div>
|
|
||||||
</WoTableShell>
|
</WoTableShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -263,72 +159,11 @@ export function WoTable({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<WoTableHead />
|
<WoTableHead />
|
||||||
<tbody>
|
<WoTableBody
|
||||||
{tableData.advActive ? (
|
tableData={tableData}
|
||||||
tableData.advResults.map((row) => renderRow(row))
|
renderRow={renderRow}
|
||||||
) : (
|
renderInlineRow={renderInlineRow}
|
||||||
<>
|
/>
|
||||||
{(tableData.unscheduled.length > 0 ||
|
|
||||||
tableData.draftDayKey === UNSCHEDULED_DAY_KEY) && (
|
|
||||||
<>
|
|
||||||
<WoTableSectionHeader
|
|
||||||
variant="unscheduled"
|
|
||||||
label="Unscheduled"
|
|
||||||
count={tableData.unscheduled.length}
|
|
||||||
collapsed={!tableData.unschedOpen}
|
|
||||||
onToggleCollapse={() => tableData.setUnschedOpen((v) => !v)}
|
|
||||||
onAddWo={() => tableData.startDayDraft(UNSCHEDULED_DAY_KEY)}
|
|
||||||
/>
|
|
||||||
{tableData.unschedOpen && (
|
|
||||||
<>
|
|
||||||
{tableData.draftDayKey === UNSCHEDULED_DAY_KEY && (
|
|
||||||
<WoTableInlineRow
|
|
||||||
draft={tableData.draft}
|
|
||||||
draftDayKey={tableData.draftDayKey}
|
|
||||||
users={users}
|
|
||||||
sites={sites}
|
|
||||||
vendors={vendors}
|
|
||||||
pmTypes={pmTypes}
|
|
||||||
allRows={tableData.rows}
|
|
||||||
onChange={tableData.setDraft}
|
|
||||||
onSave={handleSaveDraft}
|
|
||||||
onCancel={tableData.cancelDraft}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{tableData.unscheduled.map((row) => renderRow(row, true))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{tableData.displayGroups.map((group) => (
|
|
||||||
<Fragment key={group.key}>
|
|
||||||
<WoTableSectionHeader
|
|
||||||
variant="day"
|
|
||||||
label={group.label}
|
|
||||||
count={group.items.length}
|
|
||||||
isToday={group.key === tableData.todayKey}
|
|
||||||
onAddWo={() => tableData.startDayDraft(group.key)}
|
|
||||||
/>
|
|
||||||
{tableData.draftDayKey === group.key && (
|
|
||||||
<WoTableInlineRow
|
|
||||||
draft={tableData.draft}
|
|
||||||
draftDayKey={tableData.draftDayKey}
|
|
||||||
users={users}
|
|
||||||
sites={sites}
|
|
||||||
vendors={vendors}
|
|
||||||
pmTypes={pmTypes}
|
|
||||||
allRows={tableData.rows}
|
|
||||||
onChange={tableData.setDraft}
|
|
||||||
onSave={handleSaveDraft}
|
|
||||||
onCancel={tableData.cancelDraft}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{group.items.map((row) => renderRow(row))}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
</WoTableShell>
|
</WoTableShell>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,9 @@ export function WorkOrdersListHeader({
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isFetching && <CircularProgress size={14} />}
|
{isFetching && (
|
||||||
|
<CircularProgress size={14} aria-label="Refreshing work orders" role="status" />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { Dialog } from "@mui/material";
|
||||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||||
import { mapWizardDraftToFormValues } from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
|
||||||
import { isWoWizardMockEnabled } from "@/domain/work-orders/mocks/is-wo-wizard-mock-enabled";
|
import { isWoWizardMockEnabled } from "@/domain/work-orders/mocks/is-wo-wizard-mock-enabled";
|
||||||
import {
|
import {
|
||||||
getMockWoWizardLocationDetail,
|
getMockWoWizardLocationDetail,
|
||||||
|
|
@ -150,7 +149,7 @@ export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = () => createMutation.mutate(mapWizardDraftToFormValues(draft));
|
const handleCreate = () => createMutation.mutate(draft);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { getPmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||||
|
import {
|
||||||
|
DLG_INPUT_CLS,
|
||||||
|
DLG_INPUT_STYLE,
|
||||||
|
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||||
|
|
||||||
|
type WizardServiceNotesFieldsProps = {
|
||||||
|
services: { id: string | number; value: string }[];
|
||||||
|
sel: string[];
|
||||||
|
onToggle: (service: string) => void;
|
||||||
|
note: string;
|
||||||
|
setNote: (v: string) => void;
|
||||||
|
search: string;
|
||||||
|
setSearch: (v: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SvcSectionLabel({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multi-select services + notes — mirrors design ServiceNotesFields (wizard inline). */
|
||||||
|
export function WizardServiceNotesFields({
|
||||||
|
services,
|
||||||
|
sel,
|
||||||
|
onToggle,
|
||||||
|
note,
|
||||||
|
setNote,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
}: WizardServiceNotesFieldsProps) {
|
||||||
|
const filtered = search
|
||||||
|
? services.filter((s) => s.value.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
: services;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<SvcSectionLabel>Services</SvcSectionLabel>
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search services…"
|
||||||
|
className="mb-1.5 w-full rounded px-2 py-1.5 text-[12px] focus:outline-none focus:ring-1"
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="rounded"
|
||||||
|
style={{ maxHeight: 200, overflowY: "auto", border: "1px solid var(--color-border)" }}
|
||||||
|
>
|
||||||
|
{filtered.map((s) => {
|
||||||
|
const checked = sel.includes(s.value);
|
||||||
|
const isPrimary = checked && sel[0] === s.value;
|
||||||
|
const Icon = getPmTypeIcon(s.value);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={String(s.id)}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(s.value)}
|
||||||
|
className="flex w-full items-center gap-2 px-2 py-1.5 text-left transition-colors hover:bg-[#F9FAFB]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex shrink-0 items-center justify-center"
|
||||||
|
style={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 4,
|
||||||
|
border: `1px solid ${checked ? "var(--color-primary)" : "var(--color-border)"}`,
|
||||||
|
background: checked ? "var(--color-primary)" : "var(--color-bg-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{checked && <Check size={11} style={{ color: "#fff" }} />}
|
||||||
|
</span>
|
||||||
|
<Icon size={13} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
|
||||||
|
<span
|
||||||
|
className="min-w-0 flex-1 truncate text-[13px]"
|
||||||
|
style={{ color: "var(--color-text-primary)" }}
|
||||||
|
>
|
||||||
|
{s.value}
|
||||||
|
</span>
|
||||||
|
{isPrimary && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--color-primary)",
|
||||||
|
background: "var(--color-primary-tint)",
|
||||||
|
padding: "1px 6px",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Primary
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="px-2 py-3 text-[12px]" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
No services match.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<SvcSectionLabel>Notes / Issue Detail</SvcSectionLabel>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
placeholder="Describe the specific issue, location details, door number, etc."
|
||||||
|
className={`${DLG_INPUT_CLS} resize-none`}
|
||||||
|
style={{ ...DLG_INPUT_STYLE, fontSize: 13, fontFamily: "inherit" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
|
import type {
|
||||||
|
LocationOption,
|
||||||
|
VendorDropdownItem,
|
||||||
|
} from "@/domain/work-orders/types/work-order-common";
|
||||||
|
import type { WorkOrderBoardSearchQueryParams } from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import {
|
||||||
|
BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||||
|
toBoardSearchQueryParams,
|
||||||
|
} from "@/domain/work-orders/utils/board-query-params";
|
||||||
|
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
|
|
||||||
|
export type ResolvedBoardSearchParamsInput = {
|
||||||
|
advApplied: WorkOrderAdvancedFilters | null;
|
||||||
|
search: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
sortBy: string;
|
||||||
|
sortDir: string;
|
||||||
|
locations: LocationOption[];
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveSiteCodes(sites: Set<string>, locations: LocationOption[]): Set<string> {
|
||||||
|
const siteCodes = new Set<string>();
|
||||||
|
for (const siteKey of sites) {
|
||||||
|
const location = locations.find((item) => String(item.id) === siteKey);
|
||||||
|
if (location) {
|
||||||
|
const code = resolveSiteCode({ siteCode: location.siteCode, name: location.name });
|
||||||
|
if (code) {
|
||||||
|
siteCodes.add(code);
|
||||||
|
}
|
||||||
|
} else if (siteKey.trim()) {
|
||||||
|
siteCodes.add(siteKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return siteCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVendorIdsByCompanyName(vendors: VendorDropdownItem[]): Map<string, number> {
|
||||||
|
const vendorIdsByCompanyName = new Map<string, number>();
|
||||||
|
for (const vendor of vendors) {
|
||||||
|
const id = Number(vendor.id);
|
||||||
|
if (Number.isFinite(id) && vendor.companyName) {
|
||||||
|
vendorIdsByCompanyName.set(vendor.companyName, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vendorIdsByCompanyName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves advanced-filter site keys/vendor names to API-ready board/search query params. */
|
||||||
|
export function buildResolvedBoardSearchParams(
|
||||||
|
input: ResolvedBoardSearchParamsInput,
|
||||||
|
): WorkOrderBoardSearchQueryParams | null {
|
||||||
|
const { advApplied } = input;
|
||||||
|
if (!advApplied) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteCodes = resolveSiteCodes(advApplied.sites, input.locations);
|
||||||
|
const vendorIdsByCompanyName = buildVendorIdsByCompanyName(input.vendors);
|
||||||
|
|
||||||
|
return toBoardSearchQueryParams({
|
||||||
|
filters: { ...advApplied, sites: siteCodes },
|
||||||
|
search: input.search,
|
||||||
|
page: input.page,
|
||||||
|
pageSize: Math.min(input.pageSize || BOARD_SEARCH_MAX_PAGE_SIZE, BOARD_SEARCH_MAX_PAGE_SIZE),
|
||||||
|
sortBy: input.sortBy || "scheduledDate",
|
||||||
|
sortDir: input.sortDir || "asc",
|
||||||
|
vendorIdsByCompanyName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||||
|
import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params";
|
||||||
|
import {
|
||||||
|
useWorkOrdersBoard,
|
||||||
|
useWorkOrdersBoardSearch,
|
||||||
|
} from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||||
|
import {
|
||||||
|
useWorkOrderLocations,
|
||||||
|
useWorkOrderVendors,
|
||||||
|
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
|
import type { WorkOrderBoardListResult } from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import type { UseQueryResult } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
||||||
|
|
||||||
|
export type WorkOrdersBoardSource = {
|
||||||
|
advActive: boolean;
|
||||||
|
data: WorkOrderBoardListResult | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refetch: UseQueryResult<WorkOrderBoardListResult, Error>["refetch"];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Picks between the weekly board query and the advanced-filters search query. */
|
||||||
|
export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoardSource {
|
||||||
|
const advActive = filters.advApplied !== null;
|
||||||
|
const { data: locations = [] } = useWorkOrderLocations();
|
||||||
|
const { data: vendors = [] } = useWorkOrderVendors();
|
||||||
|
|
||||||
|
const resolvedSearchParams = useMemo(
|
||||||
|
() =>
|
||||||
|
buildResolvedBoardSearchParams({
|
||||||
|
advApplied: filters.advApplied,
|
||||||
|
search: filters.search,
|
||||||
|
page: filters.page,
|
||||||
|
pageSize: filters.pageSize,
|
||||||
|
sortBy: filters.sortBy,
|
||||||
|
sortDir: filters.sortDir,
|
||||||
|
locations,
|
||||||
|
vendors,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
filters.advApplied,
|
||||||
|
filters.search,
|
||||||
|
filters.page,
|
||||||
|
filters.pageSize,
|
||||||
|
filters.sortBy,
|
||||||
|
filters.sortDir,
|
||||||
|
locations,
|
||||||
|
vendors,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: boardData,
|
||||||
|
isLoading: boardLoading,
|
||||||
|
isFetching: boardFetching,
|
||||||
|
error: boardError,
|
||||||
|
refetch: refetchBoard,
|
||||||
|
} = useWorkOrdersBoard(filters.boardParams, !advActive);
|
||||||
|
const {
|
||||||
|
data: searchData,
|
||||||
|
isLoading: searchLoading,
|
||||||
|
isFetching: searchFetching,
|
||||||
|
error: searchError,
|
||||||
|
refetch: refetchSearch,
|
||||||
|
} = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive);
|
||||||
|
|
||||||
|
return {
|
||||||
|
advActive,
|
||||||
|
data: advActive ? searchData : boardData,
|
||||||
|
isLoading: advActive ? searchLoading : boardLoading,
|
||||||
|
isFetching: advActive ? searchFetching : boardFetching,
|
||||||
|
error: advActive ? searchError : boardError,
|
||||||
|
refetch: advActive ? refetchSearch : refetchBoard,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useDebounce } from "@/hooks/use-debounce";
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
|
|
||||||
import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list";
|
|
||||||
import { WORK_ORDERS_TABLE_PAGE_SIZE } from "@/domain/work-orders/types/work-order-table-row";
|
|
||||||
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
import type {
|
import type {
|
||||||
WorkOrderBarFilters,
|
WorkOrderBarFilters,
|
||||||
WorkOrderTypeFilter,
|
WorkOrderTypeFilter,
|
||||||
} from "@/domain/work-orders/types/work-order-list-filters";
|
} from "@/domain/work-orders/types/work-order-list-filters";
|
||||||
|
import type { WorkOrderBoardQueryParams } from "@/domain/work-orders/types/work-order-board";
|
||||||
import {
|
import {
|
||||||
clearAdvancedFilterGroup,
|
clearAdvancedFilterGroup,
|
||||||
cloneAdvancedFilters,
|
cloneAdvancedFilters,
|
||||||
|
|
@ -15,46 +13,21 @@ import {
|
||||||
getInitialAdvancedFilters,
|
getInitialAdvancedFilters,
|
||||||
} from "@/domain/work-orders/utils/advanced-filters-utils";
|
} from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||||
import {
|
import {
|
||||||
compactWeekLabel,
|
BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||||
currentWeekMonday,
|
toBoardQueryParams,
|
||||||
weekRange,
|
} from "@/domain/work-orders/utils/board-query-params";
|
||||||
} from "@/domain/work-orders/utils/list-week-utils";
|
import { compactWeekLabel, currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||||
|
|
||||||
function defaultDispatcherIds(currentUserId?: string | number): Set<string> {
|
export function useWorkOrdersListFilters() {
|
||||||
if (isWoTableMockEnabled()) {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
if (currentUserId != null && currentUserId !== "") {
|
|
||||||
return new Set([String(currentUserId)]);
|
|
||||||
}
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Phase 2: map bar filters to API list params. */
|
|
||||||
export function toListParamsFromBar(
|
|
||||||
bar: WorkOrderBarFilters,
|
|
||||||
): Pick<WorkOrdersListParams, "dateFrom" | "dateTo" | "type" | "assigneeIds"> {
|
|
||||||
const [dateFrom, dateTo] = weekRange(bar.weekMonday);
|
|
||||||
return {
|
|
||||||
dateFrom,
|
|
||||||
dateTo,
|
|
||||||
type: bar.type === "All" ? undefined : bar.type,
|
|
||||||
assigneeIds: bar.dispatcherIds.size > 0 ? [...bar.dispatcherIds].join(",") : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkOrdersListFilters(currentUserId?: string | number) {
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [advOpen, setAdvOpen] = useState(false);
|
const [advOpen, setAdvOpen] = useState(false);
|
||||||
const [advApplied, setAdvApplied] = useState<WorkOrderAdvancedFilters | null>(null);
|
const [advApplied, setAdvApplied] = useState<WorkOrderAdvancedFilters | null>(null);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [pageSize, setPageSize] = useState(12);
|
const [pageSize, setPageSize] = useState(BOARD_SEARCH_MAX_PAGE_SIZE);
|
||||||
const [sortBy, setSortBy] = useState("");
|
const [sortBy, setSortBy] = useState("");
|
||||||
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
|
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
|
||||||
const [weekMonday, setWeekMondayState] = useState(currentWeekMonday);
|
const [weekMonday, setWeekMondayState] = useState(currentWeekMonday);
|
||||||
const [dispatcherIds, setDispatcherIdsState] = useState<Set<string>>(() =>
|
const [dispatcherIds, setDispatcherIdsState] = useState<Set<string>>(() => new Set());
|
||||||
defaultDispatcherIds(currentUserId),
|
|
||||||
);
|
|
||||||
const [typeFilter, setTypeFilterState] = useState<WorkOrderTypeFilter>("All");
|
const [typeFilter, setTypeFilterState] = useState<WorkOrderTypeFilter>("All");
|
||||||
|
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
|
@ -70,25 +43,9 @@ export function useWorkOrdersListFilters(currentUserId?: string | number) {
|
||||||
|
|
||||||
const weekLabel = useMemo(() => compactWeekLabel(weekMonday), [weekMonday]);
|
const weekLabel = useMemo(() => compactWeekLabel(weekMonday), [weekMonday]);
|
||||||
|
|
||||||
const scopeListParams = useMemo<WorkOrdersListParams>(
|
const boardParams = useMemo<WorkOrderBoardQueryParams>(
|
||||||
() => ({
|
() => toBoardQueryParams(barFilters, debouncedSearch),
|
||||||
page: 1,
|
[barFilters, debouncedSearch],
|
||||||
pageSize: 1,
|
|
||||||
...toListParamsFromBar(barFilters),
|
|
||||||
}),
|
|
||||||
[barFilters],
|
|
||||||
);
|
|
||||||
|
|
||||||
const listParams = useMemo<WorkOrdersListParams>(
|
|
||||||
() => ({
|
|
||||||
search: debouncedSearch,
|
|
||||||
page: 1,
|
|
||||||
pageSize: WORK_ORDERS_TABLE_PAGE_SIZE,
|
|
||||||
sortBy,
|
|
||||||
sortDir,
|
|
||||||
...toListParamsFromBar(barFilters),
|
|
||||||
}),
|
|
||||||
[debouncedSearch, sortBy, sortDir, barFilters],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeFilterCount = useMemo(
|
const activeFilterCount = useMemo(
|
||||||
|
|
@ -170,8 +127,7 @@ export function useWorkOrdersListFilters(currentUserId?: string | number) {
|
||||||
typeFilter,
|
typeFilter,
|
||||||
barFilters,
|
barFilters,
|
||||||
weekLabel,
|
weekLabel,
|
||||||
listParams,
|
boardParams,
|
||||||
scopeListParams,
|
|
||||||
activeFilterCount,
|
activeFilterCount,
|
||||||
handleSearchChange,
|
handleSearchChange,
|
||||||
applyAdvancedFilters,
|
applyAdvancedFilters,
|
||||||
|
|
|
||||||
|
|
@ -1,88 +1,55 @@
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||||
import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source";
|
||||||
import {
|
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
||||||
EMPTY_TABLE_DRAFT,
|
|
||||||
UNSCHEDULED_DAY_KEY,
|
|
||||||
type WorkOrderTableDraft,
|
|
||||||
type WorkOrderTableRow,
|
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
|
||||||
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||||
import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
import {
|
import {
|
||||||
buildDayGroups,
|
buildDayGroups,
|
||||||
enrichTableRow,
|
|
||||||
filterByBarAndSearch,
|
filterByBarAndSearch,
|
||||||
matchesAdvancedFilters,
|
|
||||||
splitScheduledUnscheduled,
|
splitScheduledUnscheduled,
|
||||||
} from "@/domain/work-orders/utils/table-filter-utils";
|
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||||
|
|
||||||
|
export type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
||||||
|
|
||||||
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
||||||
|
|
||||||
export type DragVizState = {
|
|
||||||
dragId: string | number | null;
|
|
||||||
overId: string | number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useWorkOrdersTableData(filters: FiltersReturn) {
|
export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
const { data, isLoading, isFetching } = useWorkOrdersList(filters.listParams);
|
const { advActive, data, isLoading, isFetching, error, refetch } =
|
||||||
const { data: scopeData } = useWorkOrdersList(filters.scopeListParams);
|
useWorkOrdersBoardSource(filters);
|
||||||
|
|
||||||
const [patches, setPatches] = useState<Record<string, Partial<WorkOrderTableRow>>>({});
|
const local = useWorkOrdersTableLocalState({
|
||||||
const [localRows, setLocalRows] = useState<WorkOrderTableRow[]>([]);
|
items: data?.items ?? [],
|
||||||
const [rowOrder, setRowOrder] = useState<string[] | null>(null);
|
weekMonday: filters.weekMonday,
|
||||||
const [flags, setFlagsState] = useState<Record<string, string>>({});
|
advActive,
|
||||||
const [draftDayKey, setDraftDayKey] = useState<string | null>(null);
|
isFetching,
|
||||||
const [draft, setDraft] = useState<WorkOrderTableDraft>(EMPTY_TABLE_DRAFT);
|
});
|
||||||
const [unschedOpen, setUnschedOpen] = useState(true);
|
const { displayRows } = local;
|
||||||
const [selectedId, setSelectedId] = useState<string | number | null>(null);
|
|
||||||
const [flashId, setFlashId] = useState<string | number | null>(null);
|
|
||||||
const [dragViz, setDragViz] = useState<DragVizState>({ dragId: null, overId: null });
|
|
||||||
const dragState = useRef<DragVizState>({ dragId: null, overId: null });
|
|
||||||
|
|
||||||
const apiRows = useMemo(
|
const advResults = useMemo(() => (advActive ? displayRows : []), [advActive, displayRows]);
|
||||||
() => (data?.items ?? []).map((row) => enrichTableRow({ ...row, ...patches[String(row.id)] })),
|
|
||||||
[data?.items, patches],
|
|
||||||
);
|
|
||||||
|
|
||||||
const displayRows = useMemo(() => {
|
|
||||||
const apiIds = new Set(apiRows.map((r) => String(r.id)));
|
|
||||||
const extra = localRows.filter((r) => !apiIds.has(String(r.id)));
|
|
||||||
let merged = [...apiRows, ...extra.map(enrichTableRow)];
|
|
||||||
if (rowOrder?.length) {
|
|
||||||
const orderMap = new Map(rowOrder.map((id, idx) => [id, idx]));
|
|
||||||
merged = [...merged].sort((a, b) => {
|
|
||||||
const ai = orderMap.get(String(a.id)) ?? 9999;
|
|
||||||
const bi = orderMap.get(String(b.id)) ?? 9999;
|
|
||||||
return ai - bi;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return merged;
|
|
||||||
}, [apiRows, localRows, rowOrder]);
|
|
||||||
|
|
||||||
const advActive = filters.advApplied !== null;
|
|
||||||
const advResults = useMemo(() => {
|
|
||||||
if (!filters.advApplied) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return displayRows.filter((row) => matchesAdvancedFilters(row, filters.advApplied!));
|
|
||||||
}, [displayRows, filters.advApplied]);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (advActive) {
|
if (advActive) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return filterByBarAndSearch(displayRows, filters.barFilters, filters.search);
|
if (filters.search.trim().length > 0 && filters.search.trim().length < 2) {
|
||||||
|
return filterByBarAndSearch(displayRows, filters.barFilters, filters.search);
|
||||||
|
}
|
||||||
|
return displayRows;
|
||||||
}, [displayRows, filters.barFilters, filters.search, advActive]);
|
}, [displayRows, filters.barFilters, filters.search, advActive]);
|
||||||
|
|
||||||
const { scheduled, unscheduled } = useMemo(() => splitScheduledUnscheduled(filtered), [filtered]);
|
const { scheduled, unscheduled } = useMemo(
|
||||||
|
() => (advActive ? splitScheduledUnscheduled(advResults) : splitScheduledUnscheduled(filtered)),
|
||||||
|
[advActive, advResults, filtered],
|
||||||
|
);
|
||||||
|
|
||||||
const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]);
|
const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]);
|
||||||
const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]);
|
const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]);
|
||||||
|
|
||||||
const searchActive = filters.search.trim() !== "";
|
const searchActive = filters.search.trim() !== "";
|
||||||
const noSearchResults = filtered.length === 0 && searchActive;
|
const visibleRows = advActive ? advResults : filtered;
|
||||||
const isEmptyWeek = scheduled.length === 0 && !searchActive;
|
const noSearchResults = visibleRows.length === 0 && searchActive;
|
||||||
|
const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
|
||||||
const displayGroups = isEmptyWeek ? weekSkeleton : dayGroups;
|
const displayGroups = isEmptyWeek ? weekSkeleton : dayGroups;
|
||||||
|
|
||||||
const todayKey =
|
const todayKey =
|
||||||
|
|
@ -91,102 +58,16 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const selectedRow = useMemo(
|
const selectedRow = useMemo(
|
||||||
() => displayRows.find((row) => String(row.id) === String(selectedId)) ?? null,
|
() => displayRows.find((row) => String(row.id) === String(local.selectedId)) ?? null,
|
||||||
[displayRows, selectedId],
|
[displayRows, local.selectedId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setFlag = useCallback((id: string | number, color: string | null) => {
|
|
||||||
setFlagsState((prev) => {
|
|
||||||
const key = String(id);
|
|
||||||
if (!color) {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[key];
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
return { ...prev, [key]: color };
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const patchRow = useCallback((id: string | number, patch: Partial<WorkOrderTableRow>) => {
|
|
||||||
const key = String(id);
|
|
||||||
setPatches((prev) => {
|
|
||||||
const merged = { ...prev[key], ...patch };
|
|
||||||
if (patch.scheduledOn !== undefined) {
|
|
||||||
const dayFields = deriveDayFields(patch.scheduledOn);
|
|
||||||
merged.dayGroup = dayFields.dayGroup;
|
|
||||||
merged.dayLabel = dayFields.dayLabel;
|
|
||||||
}
|
|
||||||
return { ...prev, [key]: merged };
|
|
||||||
});
|
|
||||||
setLocalRows((prev) =>
|
|
||||||
prev.map((row) => (String(row.id) === key ? enrichTableRow({ ...row, ...patch }) : row)),
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const reorderRows = useCallback(
|
|
||||||
(dragId: string | number, overId: string | number) => {
|
|
||||||
const dragKey = String(dragId);
|
|
||||||
const overKey = String(overId);
|
|
||||||
const dragRow = displayRows.find((r) => String(r.id) === dragKey);
|
|
||||||
const overRow = displayRows.find((r) => String(r.id) === overKey);
|
|
||||||
if (!dragRow || !overRow || dragRow.dayGroup !== overRow.dayGroup) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ids = displayRows.map((r) => String(r.id));
|
|
||||||
const dragIdx = ids.indexOf(dragKey);
|
|
||||||
const overIdx = ids.indexOf(overKey);
|
|
||||||
if (dragIdx < 0 || overIdx < 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const next = [...ids];
|
|
||||||
next.splice(dragIdx, 1);
|
|
||||||
next.splice(overIdx, 0, dragKey);
|
|
||||||
setRowOrder(next);
|
|
||||||
},
|
|
||||||
[displayRows],
|
|
||||||
);
|
|
||||||
|
|
||||||
const endDrag = useCallback(() => {
|
|
||||||
dragState.current = { dragId: null, overId: null };
|
|
||||||
setDragViz({ dragId: null, overId: null });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startDayDraft = useCallback(
|
|
||||||
(key: string) => {
|
|
||||||
setDraftDayKey(key);
|
|
||||||
const scheduledOn =
|
|
||||||
key === UNSCHEDULED_DAY_KEY
|
|
||||||
? ""
|
|
||||||
: (() => {
|
|
||||||
const [mm, dd] = key.split("/").map(Number);
|
|
||||||
const year = filters.weekMonday.slice(0, 4);
|
|
||||||
return `${year}-${String(mm).padStart(2, "0")}-${String(dd).padStart(2, "0")}`;
|
|
||||||
})();
|
|
||||||
setDraft({
|
|
||||||
...EMPTY_TABLE_DRAFT,
|
|
||||||
scheduledOn,
|
|
||||||
scheduleWeekOnly: key === UNSCHEDULED_DAY_KEY,
|
|
||||||
targetWeek: key === UNSCHEDULED_DAY_KEY ? filters.weekMonday : "",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[filters.weekMonday],
|
|
||||||
);
|
|
||||||
|
|
||||||
const cancelDraft = useCallback(() => {
|
|
||||||
setDraftDayKey(null);
|
|
||||||
setDraft(EMPTY_TABLE_DRAFT);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const addRow = useCallback((row: WorkOrderTableRow) => {
|
|
||||||
setLocalRows((prev) => [...prev, enrichTableRow(row)]);
|
|
||||||
setFlashId(row.id);
|
|
||||||
setTimeout(() => setFlashId(null), 2000);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: displayRows,
|
rows: displayRows,
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
advActive,
|
advActive,
|
||||||
advResults,
|
advResults,
|
||||||
unscheduled,
|
unscheduled,
|
||||||
|
|
@ -195,28 +76,30 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
noSearchResults,
|
noSearchResults,
|
||||||
isEmptyWeek,
|
isEmptyWeek,
|
||||||
todayKey,
|
todayKey,
|
||||||
flags,
|
draftDayKey: local.draftDayKey,
|
||||||
setFlag,
|
draft: local.draft,
|
||||||
draftDayKey,
|
setDraft: local.setDraft,
|
||||||
draft,
|
unschedOpen: local.unschedOpen,
|
||||||
setDraft,
|
setUnschedOpen: local.setUnschedOpen,
|
||||||
unschedOpen,
|
selectedId: local.selectedId,
|
||||||
setUnschedOpen,
|
setSelectedId: local.setSelectedId,
|
||||||
selectedId,
|
|
||||||
setSelectedId,
|
|
||||||
selectedRow,
|
selectedRow,
|
||||||
flashId,
|
flashId: local.flashId,
|
||||||
dragViz,
|
triggerFlash: local.triggerFlash,
|
||||||
setDragViz,
|
dragViz: local.dragViz,
|
||||||
dragState,
|
setDragViz: local.setDragViz,
|
||||||
patchRow,
|
dragState: local.dragState,
|
||||||
reorderRows,
|
patchRow: local.patchRow,
|
||||||
endDrag,
|
clearRowPatch: local.clearRowPatch,
|
||||||
startDayDraft,
|
reorderRows: local.reorderRows,
|
||||||
cancelDraft,
|
endDrag: local.endDrag,
|
||||||
addRow,
|
startDayDraft: local.startDayDraft,
|
||||||
scopeTotalCount: scopeData?.totalCount ?? 0,
|
cancelDraft: local.cancelDraft,
|
||||||
totalCount: data?.totalCount ?? displayRows.length,
|
addRow: local.addRow,
|
||||||
|
scopeTotalCount: data?.totalCount ?? 0,
|
||||||
|
totalCount: advActive
|
||||||
|
? (data?.totalCount ?? advResults.length)
|
||||||
|
: (data?.returnedCount ?? visibleRows.length),
|
||||||
search: filters.search,
|
search: filters.search,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
|
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
|
||||||
|
import {
|
||||||
|
applyRowOrderByDay,
|
||||||
|
loadBoardRowOrder,
|
||||||
|
mergeDayOrder,
|
||||||
|
removeIdFromRowOrder,
|
||||||
|
reorderIdsInList,
|
||||||
|
saveBoardRowOrder,
|
||||||
|
type BoardRowOrderByDay,
|
||||||
|
} from "@/domain/work-orders/utils/board-row-order-storage";
|
||||||
|
import {
|
||||||
|
useWorkOrdersTableUiState,
|
||||||
|
type DragVizState,
|
||||||
|
} from "@/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state";
|
||||||
|
|
||||||
|
export type { DragVizState };
|
||||||
|
|
||||||
|
export type UseWorkOrdersTableLocalStateParams = {
|
||||||
|
items: WorkOrderTableRow[];
|
||||||
|
weekMonday: string;
|
||||||
|
advActive: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function usePrunePatchesOnFetchSettle(
|
||||||
|
isFetching: boolean,
|
||||||
|
setPatches: React.Dispatch<React.SetStateAction<Record<string, Partial<WorkOrderTableRow>>>>,
|
||||||
|
) {
|
||||||
|
const wasFetchingRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (wasFetchingRef.current && !isFetching) {
|
||||||
|
setPatches({});
|
||||||
|
}
|
||||||
|
wasFetchingRef.current = isFetching;
|
||||||
|
}, [isFetching, setPatches]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owns board local overlays (patches/added rows), row ordering, draft, selection and drag/flash UI state. */
|
||||||
|
export function useWorkOrdersTableLocalState({
|
||||||
|
items,
|
||||||
|
weekMonday,
|
||||||
|
advActive,
|
||||||
|
isFetching,
|
||||||
|
}: UseWorkOrdersTableLocalStateParams) {
|
||||||
|
const [patches, setPatches] = useState<Record<string, Partial<WorkOrderTableRow>>>({});
|
||||||
|
const [localRows, setLocalRows] = useState<WorkOrderTableRow[]>([]);
|
||||||
|
const [rowOrderByDay, setRowOrderByDay] = useState<BoardRowOrderByDay>({});
|
||||||
|
const ui = useWorkOrdersTableUiState(weekMonday);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRowOrderByDay(loadBoardRowOrder(weekMonday));
|
||||||
|
}, [weekMonday]);
|
||||||
|
|
||||||
|
const apiRows = useMemo(
|
||||||
|
() => items.map((row) => enrichTableRow({ ...row, ...patches[String(row.id)] })),
|
||||||
|
[items, patches],
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayRows = useMemo(() => {
|
||||||
|
const apiIds = new Set(apiRows.map((r) => String(r.id)));
|
||||||
|
const extra = localRows.filter((r) => !apiIds.has(String(r.id)));
|
||||||
|
return applyRowOrderByDay([...apiRows, ...extra.map(enrichTableRow)], rowOrderByDay);
|
||||||
|
}, [apiRows, localRows, rowOrderByDay]);
|
||||||
|
|
||||||
|
const clearRowPatch = useCallback((id: string | number) => {
|
||||||
|
const key = String(id);
|
||||||
|
setPatches((prev) => {
|
||||||
|
if (!(key in prev)) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
usePrunePatchesOnFetchSettle(isFetching, setPatches);
|
||||||
|
|
||||||
|
const patchRow = useCallback(
|
||||||
|
(id: string | number, patch: Partial<WorkOrderTableRow>) => {
|
||||||
|
const key = String(id);
|
||||||
|
const previousRow =
|
||||||
|
displayRows.find((row) => String(row.id) === key) ??
|
||||||
|
localRows.find((row) => String(row.id) === key);
|
||||||
|
const previousDayGroup = previousRow?.dayGroup;
|
||||||
|
|
||||||
|
setPatches((prev) => {
|
||||||
|
const merged = { ...prev[key], ...patch };
|
||||||
|
if (patch.scheduledOn !== undefined) {
|
||||||
|
const dayFields = deriveDayFields(patch.scheduledOn);
|
||||||
|
merged.dayGroup = dayFields.dayGroup;
|
||||||
|
merged.dayLabel = dayFields.dayLabel;
|
||||||
|
}
|
||||||
|
return { ...prev, [key]: merged };
|
||||||
|
});
|
||||||
|
setLocalRows((prev) =>
|
||||||
|
prev.map((row) => (String(row.id) === key ? enrichTableRow({ ...row, ...patch }) : row)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (patch.scheduledOn === undefined || advActive) return;
|
||||||
|
|
||||||
|
const nextDayGroup = deriveDayFields(patch.scheduledOn).dayGroup;
|
||||||
|
if (!previousDayGroup || previousDayGroup === nextDayGroup) return;
|
||||||
|
|
||||||
|
setRowOrderByDay((prev) => {
|
||||||
|
const next = removeIdFromRowOrder(prev, key);
|
||||||
|
if (next === prev) return prev;
|
||||||
|
saveBoardRowOrder(weekMonday, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[advActive, displayRows, weekMonday, localRows],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reorderRows = useCallback(
|
||||||
|
(dragId: string | number, overId: string | number) => {
|
||||||
|
const dragKey = String(dragId);
|
||||||
|
const overKey = String(overId);
|
||||||
|
const dragRow = displayRows.find((r) => String(r.id) === dragKey);
|
||||||
|
const overRow = displayRows.find((r) => String(r.id) === overKey);
|
||||||
|
if (!dragRow || !overRow || dragRow.dayGroup !== overRow.dayGroup) return;
|
||||||
|
|
||||||
|
const dayGroup = dragRow.dayGroup;
|
||||||
|
const dayIds = displayRows
|
||||||
|
.filter((row) => row.dayGroup === dayGroup)
|
||||||
|
.map((row) => String(row.id));
|
||||||
|
const reorderedVisible = reorderIdsInList(dayIds, dragKey, overKey);
|
||||||
|
if (!reorderedVisible) return;
|
||||||
|
|
||||||
|
setRowOrderByDay((prev) => {
|
||||||
|
const next = { ...prev, [dayGroup]: mergeDayOrder(prev[dayGroup], reorderedVisible) };
|
||||||
|
if (!advActive) saveBoardRowOrder(weekMonday, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[advActive, displayRows, weekMonday],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { triggerFlash } = ui;
|
||||||
|
const addRow = useCallback(
|
||||||
|
(row: WorkOrderTableRow) => {
|
||||||
|
setLocalRows((prev) => [...prev, enrichTableRow(row)]);
|
||||||
|
triggerFlash(row.id);
|
||||||
|
},
|
||||||
|
[triggerFlash],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayRows,
|
||||||
|
localRows,
|
||||||
|
patchRow,
|
||||||
|
clearRowPatch,
|
||||||
|
reorderRows,
|
||||||
|
endDrag: ui.endDrag,
|
||||||
|
draftDayKey: ui.draftDayKey,
|
||||||
|
draft: ui.draft,
|
||||||
|
setDraft: ui.setDraft,
|
||||||
|
startDayDraft: ui.startDayDraft,
|
||||||
|
cancelDraft: ui.cancelDraft,
|
||||||
|
unschedOpen: ui.unschedOpen,
|
||||||
|
setUnschedOpen: ui.setUnschedOpen,
|
||||||
|
selectedId: ui.selectedId,
|
||||||
|
setSelectedId: ui.setSelectedId,
|
||||||
|
flashId: ui.flashId,
|
||||||
|
triggerFlash: ui.triggerFlash,
|
||||||
|
dragViz: ui.dragViz,
|
||||||
|
setDragViz: ui.setDragViz,
|
||||||
|
dragState: ui.dragState,
|
||||||
|
addRow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
EMPTY_TABLE_DRAFT,
|
||||||
|
UNSCHEDULED_DAY_KEY,
|
||||||
|
type WorkOrderTableDraft,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
export type DragVizState = {
|
||||||
|
dragId: string | number | null;
|
||||||
|
overId: string | number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveDraftScheduledOn(key: string, weekMonday: string, scheduledIso?: string): string {
|
||||||
|
if (key === UNSCHEDULED_DAY_KEY) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (scheduledIso && /^\d{4}-\d{2}-\d{2}$/.test(scheduledIso)) {
|
||||||
|
return scheduledIso;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = new Date(`${weekMonday}T12:00:00`);
|
||||||
|
for (let i = 0; i < 7; i += 1) {
|
||||||
|
const d = new Date(base);
|
||||||
|
d.setDate(base.getDate() + i);
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
if (`${mm}/${dd}` === key) {
|
||||||
|
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draft, selection, unsched accordion, drag viz and flash scroll helpers. */
|
||||||
|
export function useWorkOrdersTableUiState(weekMonday: string) {
|
||||||
|
const [draftDayKey, setDraftDayKey] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState<WorkOrderTableDraft>(EMPTY_TABLE_DRAFT);
|
||||||
|
const [unschedOpen, setUnschedOpen] = useState(true);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | number | null>(null);
|
||||||
|
const [flashId, setFlashId] = useState<string | number | null>(null);
|
||||||
|
const [dragViz, setDragViz] = useState<DragVizState>({ dragId: null, overId: null });
|
||||||
|
const dragState = useRef<DragVizState>({ dragId: null, overId: null });
|
||||||
|
|
||||||
|
const endDrag = useCallback(() => {
|
||||||
|
dragState.current = { dragId: null, overId: null };
|
||||||
|
setDragViz({ dragId: null, overId: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startDayDraft = useCallback(
|
||||||
|
(key: string, scheduledIso?: string) => {
|
||||||
|
setDraftDayKey(key);
|
||||||
|
const scheduledOn = resolveDraftScheduledOn(key, weekMonday, scheduledIso);
|
||||||
|
setDraft({
|
||||||
|
...EMPTY_TABLE_DRAFT,
|
||||||
|
scheduledOn,
|
||||||
|
scheduleWeekOnly: key === UNSCHEDULED_DAY_KEY,
|
||||||
|
targetWeek: key === UNSCHEDULED_DAY_KEY ? weekMonday : "",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[weekMonday],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelDraft = useCallback(() => {
|
||||||
|
setDraftDayKey(null);
|
||||||
|
setDraft(EMPTY_TABLE_DRAFT);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const triggerFlash = useCallback((id: string | number) => {
|
||||||
|
setFlashId(id);
|
||||||
|
setTimeout(() => {
|
||||||
|
document
|
||||||
|
.getElementById(`wo-row-${id}`)
|
||||||
|
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}, 80);
|
||||||
|
setTimeout(() => setFlashId(null), 2500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
draftDayKey,
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
startDayDraft,
|
||||||
|
cancelDraft,
|
||||||
|
unschedOpen,
|
||||||
|
setUnschedOpen,
|
||||||
|
selectedId,
|
||||||
|
setSelectedId,
|
||||||
|
flashId,
|
||||||
|
triggerFlash,
|
||||||
|
dragViz,
|
||||||
|
setDragViz,
|
||||||
|
dragState,
|
||||||
|
endDrag,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { Box } from "@mui/material";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { PageHeader } from "@/components/ui/page-header";
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
||||||
|
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||||
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 { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
|
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
|
||||||
|
|
@ -18,8 +19,6 @@ import {
|
||||||
useWorkOrderVendors,
|
useWorkOrderVendors,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||||
import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
|
|
||||||
import { getMockPmTypes } from "@/domain/work-orders/mocks/wo-table-mock-data";
|
|
||||||
import type { CompDocData } from "@/domain/work-orders/types/completion-doc";
|
import type { CompDocData } from "@/domain/work-orders/types/completion-doc";
|
||||||
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 { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||||
|
|
@ -33,20 +32,21 @@ export default function WorkOrdersListPage() {
|
||||||
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 [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
|
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
|
||||||
const filters = useWorkOrdersListFilters(user?.id);
|
const filters = useWorkOrdersListFilters();
|
||||||
const tableData = useWorkOrdersTableData(filters);
|
const tableData = useWorkOrdersTableData(filters);
|
||||||
const { patchField } = useWorkOrderTableMutations(tableData.patchRow);
|
const { patchField } = useWorkOrderTableMutations({
|
||||||
|
onPatch: tableData.patchRow,
|
||||||
|
clearPatch: tableData.clearRowPatch,
|
||||||
|
getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)),
|
||||||
|
});
|
||||||
|
|
||||||
const { data: users = [] } = useWorkOrderUsers();
|
const { data: users = [], isLoading: usersLoading } = useWorkOrderUsers();
|
||||||
const { data: locations = [] } = useWorkOrderLocations();
|
const { data: locations = [] } = useWorkOrderLocations();
|
||||||
const { data: vendors = [] } = useWorkOrderVendors();
|
const { data: vendors = [] } = useWorkOrderVendors();
|
||||||
const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
|
const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
|
||||||
|
|
||||||
const pmTypes = useMemo(
|
const pmTypes = useMemo(
|
||||||
() =>
|
() => problemOptions.map((option) => option.value).filter(Boolean),
|
||||||
isWoTableMockEnabled()
|
|
||||||
? getMockPmTypes()
|
|
||||||
: problemOptions.map((option) => option.value).filter(Boolean),
|
|
||||||
[problemOptions],
|
[problemOptions],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -94,6 +94,7 @@ export default function WorkOrdersListPage() {
|
||||||
typeFilter={filters.typeFilter}
|
typeFilter={filters.typeFilter}
|
||||||
onTypeChange={filters.setTypeFilter}
|
onTypeChange={filters.setTypeFilter}
|
||||||
users={users}
|
users={users}
|
||||||
|
usersLoading={usersLoading}
|
||||||
currentUserId={user?.id}
|
currentUserId={user?.id}
|
||||||
filteredCount={totalCount}
|
filteredCount={totalCount}
|
||||||
scopeTotalCount={scopeTotalCount}
|
scopeTotalCount={scopeTotalCount}
|
||||||
|
|
@ -110,12 +111,27 @@ export default function WorkOrdersListPage() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{Boolean(tableData.error) && (
|
||||||
|
<QueryErrorPanel
|
||||||
|
className="mb-2 shrink-0"
|
||||||
|
compact
|
||||||
|
message={
|
||||||
|
tableData.error instanceof Error
|
||||||
|
? tableData.error.message
|
||||||
|
: "Failed to load work orders."
|
||||||
|
}
|
||||||
|
onRetry={() => void tableData.refetch()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<WoTable
|
<WoTable
|
||||||
tableData={tableData}
|
tableData={tableData}
|
||||||
users={users}
|
users={users}
|
||||||
sites={locations}
|
sites={locations}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
pmTypes={pmTypes}
|
pmTypes={pmTypes}
|
||||||
|
weekMonday={filters.weekMonday}
|
||||||
|
onWeekChange={filters.setWeekMonday}
|
||||||
onOpenSlideOver={(row, edit) => {
|
onOpenSlideOver={(row, edit) => {
|
||||||
tableData.setSelectedId(row.id);
|
tableData.setSelectedId(row.id);
|
||||||
navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`);
|
navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`);
|
||||||
|
|
@ -127,6 +143,7 @@ export default function WorkOrdersListPage() {
|
||||||
onOpenMedia={setMediaRow}
|
onOpenMedia={setMediaRow}
|
||||||
onClearSearch={() => filters.handleSearchChange("")}
|
onClearSearch={() => filters.handleSearchChange("")}
|
||||||
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||||
|
onPatch={patchField}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WorkOrdersAdvancedSearchSheet
|
<WorkOrdersAdvancedSearchSheet
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { NavLink } from "react-router";
|
import { Link, useLocation } from "react-router";
|
||||||
import { Box, Drawer } from "@mui/material";
|
import { Box, Drawer } from "@mui/material";
|
||||||
|
|
||||||
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
|
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
|
||||||
import { getSidebarWidth, navGroups, type NavItemDef } from "@/config/menu";
|
import { getActiveNavPath, getSidebarWidth, navGroups, type NavItemDef } from "@/config/menu";
|
||||||
|
|
||||||
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
|
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
|
|
@ -31,88 +31,88 @@ function getNavItemAppearance(isActive: boolean, isHover: boolean) {
|
||||||
|
|
||||||
type NavItemProps = NavItemDef & {
|
type NavItemProps = NavItemDef & {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
end?: boolean;
|
active: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function NavItem({ label, icon: Icon, path, collapsed, end, badge }: NavItemProps) {
|
function NavItem({ label, icon: Icon, path, collapsed, active, badge }: NavItemProps) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const isHover = hovered && !active;
|
||||||
|
const { itemColor, iconColor, itemBg } = getNavItemAppearance(active, isHover);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavLink to={path} end={end} className="no-underline" title={collapsed ? label : undefined}>
|
<Link
|
||||||
{({ isActive }) => {
|
to={path}
|
||||||
const isHover = hovered && !isActive;
|
aria-current={active ? "page" : undefined}
|
||||||
const { itemColor, iconColor, itemBg } = getNavItemAppearance(isActive, isHover);
|
className="no-underline"
|
||||||
|
title={collapsed ? label : undefined}
|
||||||
return (
|
>
|
||||||
|
<Box
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
width: "100%",
|
||||||
|
height: 40,
|
||||||
|
gap: "12px",
|
||||||
|
px: collapsed ? 0 : "12px",
|
||||||
|
justifyContent: collapsed ? "center" : "flex-start",
|
||||||
|
borderRadius: "6px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: active ? 600 : 500,
|
||||||
|
color: itemColor,
|
||||||
|
backgroundColor: itemBg,
|
||||||
|
boxShadow: active ? "var(--color-sidebar-item-active-shadow)" : "none",
|
||||||
|
transition: "all var(--duration-fast, 150ms) var(--ease-default, ease)",
|
||||||
|
mb: "4px",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
<Box
|
<Box
|
||||||
onMouseEnter={() => setHovered(true)}
|
component="span"
|
||||||
onMouseLeave={() => setHovered(false)}
|
|
||||||
sx={{
|
sx={{
|
||||||
position: "relative",
|
position: "absolute",
|
||||||
display: "flex",
|
left: 0,
|
||||||
alignItems: "center",
|
top: 8,
|
||||||
width: "100%",
|
bottom: 8,
|
||||||
height: 40,
|
width: 3,
|
||||||
gap: "12px",
|
borderRadius: "0 3px 3px 0",
|
||||||
px: collapsed ? 0 : "12px",
|
backgroundColor: "var(--primary)",
|
||||||
justifyContent: collapsed ? "center" : "flex-start",
|
|
||||||
borderRadius: "6px",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: isActive ? 600 : 500,
|
|
||||||
color: itemColor,
|
|
||||||
backgroundColor: itemBg,
|
|
||||||
boxShadow: isActive ? "var(--color-sidebar-item-active-shadow)" : "none",
|
|
||||||
transition: "all var(--duration-fast, 150ms) var(--ease-default, ease)",
|
|
||||||
mb: "4px",
|
|
||||||
textAlign: "left",
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{isActive && (
|
)}
|
||||||
|
<Icon size={19} className="shrink-0" style={{ color: iconColor }} />
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<Box className="truncate flex-1">{label}</Box>
|
||||||
|
{badge != null && (
|
||||||
<Box
|
<Box
|
||||||
component="span"
|
component="span"
|
||||||
|
className="inline-flex shrink-0 items-center justify-center"
|
||||||
sx={{
|
sx={{
|
||||||
position: "absolute",
|
height: 18,
|
||||||
left: 0,
|
minWidth: 18,
|
||||||
top: 8,
|
px: "6px",
|
||||||
bottom: 8,
|
borderRadius: "9999px",
|
||||||
width: 3,
|
backgroundColor: "var(--accent)",
|
||||||
borderRadius: "0 3px 3px 0",
|
color: "#ffffff",
|
||||||
backgroundColor: "var(--primary)",
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1,
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{badge}
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Icon size={19} className="shrink-0" style={{ color: iconColor }} />
|
</>
|
||||||
{!collapsed && (
|
)}
|
||||||
<>
|
</Box>
|
||||||
<Box className="truncate flex-1">{label}</Box>
|
</Link>
|
||||||
{badge != null && (
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
className="inline-flex shrink-0 items-center justify-center"
|
|
||||||
sx={{
|
|
||||||
height: 18,
|
|
||||||
minWidth: 18,
|
|
||||||
px: "6px",
|
|
||||||
borderRadius: "9999px",
|
|
||||||
backgroundColor: "var(--accent)",
|
|
||||||
color: "#ffffff",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 700,
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{badge}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</NavLink>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,6 +122,8 @@ type AppSidebarProps = {
|
||||||
|
|
||||||
export function AppSidebar({ collapsed }: AppSidebarProps) {
|
export function AppSidebar({ collapsed }: AppSidebarProps) {
|
||||||
const width = getSidebarWidth(collapsed);
|
const width = getSidebarWidth(collapsed);
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const activePath = getActiveNavPath(pathname);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
|
|
@ -195,7 +197,7 @@ export function AppSidebar({ collapsed }: AppSidebarProps) {
|
||||||
path={path}
|
path={path}
|
||||||
badge={badge}
|
badge={badge}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
end={path === "/"}
|
active={path === activePath}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -59,3 +59,13 @@ export const navGroups: NavGroup[] = [
|
||||||
// items: [{ label: "Documents", icon: FileText, path: "/documents" }],
|
// items: [{ label: "Documents", icon: FileText, path: "/documents" }],
|
||||||
// },
|
// },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export function getActiveNavPath(pathname: string): string | undefined {
|
||||||
|
return navGroups
|
||||||
|
.flatMap((group) => group.items)
|
||||||
|
.map((item) => item.path)
|
||||||
|
.filter((path) =>
|
||||||
|
path === "/" ? pathname === path : pathname === path || pathname.startsWith(`${path}/`),
|
||||||
|
)
|
||||||
|
.sort((firstPath, secondPath) => secondPath.length - firstPath.length)[0];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-res
|
||||||
import {
|
import {
|
||||||
mapLocation,
|
mapLocation,
|
||||||
mapLocationListItems,
|
mapLocationListItems,
|
||||||
|
mapLocationSites,
|
||||||
mapLocationToBackend,
|
mapLocationToBackend,
|
||||||
} from "@/domain/locations/mappers/location-mapper";
|
} from "@/domain/locations/mappers/location-mapper";
|
||||||
import type {
|
import type {
|
||||||
Location,
|
Location,
|
||||||
|
LocationSite,
|
||||||
LocationsListParams,
|
LocationsListParams,
|
||||||
LocationsListResult,
|
LocationsListResult,
|
||||||
} from "@/domain/locations/types/location";
|
} from "@/domain/locations/types/location";
|
||||||
|
|
@ -42,6 +44,14 @@ export const locationsApi = {
|
||||||
return mapLocation(handleApiResponse(data));
|
return mapLocation(handleApiResponse(data));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getSites: async (search?: string): Promise<LocationSite[]> => {
|
||||||
|
const data = await apiGet<unknown>(API_PATHS.location.sites, {
|
||||||
|
searchParams: search?.trim() ? { search: search.trim() } : undefined,
|
||||||
|
});
|
||||||
|
const items = handleApiResponse<unknown[]>(data);
|
||||||
|
return Array.isArray(items) ? mapLocationSites(items) : [];
|
||||||
|
},
|
||||||
|
|
||||||
create: async (payload: unknown): Promise<Location> => {
|
create: async (payload: unknown): Promise<Location> => {
|
||||||
const data = await apiPost<unknown>(API_PATHS.rest.locations, mapLocationToBackend(payload));
|
const data = await apiPost<unknown>(API_PATHS.rest.locations, mapLocationToBackend(payload));
|
||||||
return mapLocation(handleApiResponse(data));
|
return mapLocation(handleApiResponse(data));
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Location, LocationListItem } from "@/domain/locations/types/location";
|
import type { Location, LocationListItem, LocationSite } from "@/domain/locations/types/location";
|
||||||
|
|
||||||
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
|
|
@ -29,6 +29,67 @@ function readOptionalId(record: Record<string, unknown>): string | number {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatLocationSiteLabel(
|
||||||
|
site: Pick<LocationSite, "code" | "city" | "state">,
|
||||||
|
): string {
|
||||||
|
const place = [site.city, site.state].filter(Boolean).join(", ");
|
||||||
|
if (site.code && place) {
|
||||||
|
return `${site.code} · ${place}`;
|
||||||
|
}
|
||||||
|
return site.code || place || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Table cell label: `BK5 . Dallas, TX` (period separator). */
|
||||||
|
export function formatSiteTableLabel(params: {
|
||||||
|
code?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
fallbackLocation?: string;
|
||||||
|
}): string {
|
||||||
|
const code = params.code?.trim() ?? "";
|
||||||
|
const place =
|
||||||
|
[params.city, params.state].filter(Boolean).join(", ") ||
|
||||||
|
(params.fallbackLocation?.trim() ?? "");
|
||||||
|
if (code && place) {
|
||||||
|
return `${code} . ${place}`;
|
||||||
|
}
|
||||||
|
return code || place || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSitePlace(city?: string, state?: string, fallbackLocation = ""): string {
|
||||||
|
return [city, state].filter(Boolean).join(", ") || fallbackLocation.trim() || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wizard preview: `3811 Distribution Dr, Dallas, TX 70068` */
|
||||||
|
export function formatLocationAddressPreview(
|
||||||
|
location: Pick<Location, "address" | "city" | "state" | "zipCode">,
|
||||||
|
): string {
|
||||||
|
const street = location.address?.trim() ?? "";
|
||||||
|
const city = location.city?.trim() ?? "";
|
||||||
|
const stateZip = [location.state?.trim(), location.zipCode?.trim()].filter(Boolean).join(" ");
|
||||||
|
const cityStateZip = [city, stateZip].filter(Boolean).join(", ");
|
||||||
|
|
||||||
|
if (street && cityStateZip) {
|
||||||
|
return `${street}, ${cityStateZip}`;
|
||||||
|
}
|
||||||
|
return street || cityStateZip || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapLocationSite(raw: unknown): LocationSite {
|
||||||
|
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: readId(item),
|
||||||
|
code: readString(item, "code", "Code", "siteCode", "SiteCode"),
|
||||||
|
city: readString(item, "city", "City"),
|
||||||
|
state: readString(item, "state", "State"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapLocationSites(rawItems: unknown[]): LocationSite[] {
|
||||||
|
return rawItems.map(mapLocationSite);
|
||||||
|
}
|
||||||
|
|
||||||
export function mapLocationListItem(raw: unknown): LocationListItem {
|
export function mapLocationListItem(raw: unknown): LocationListItem {
|
||||||
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
|
@ -54,10 +115,17 @@ export function mapLocationListItems(rawItems: unknown[]): LocationListItem[] {
|
||||||
export function mapLocation(raw: unknown): Location {
|
export function mapLocation(raw: unknown): Location {
|
||||||
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const address =
|
||||||
|
readString(item, "address", "Address") ||
|
||||||
|
[readString(item, "address1", "Address1"), readString(item, "address2", "Address2")]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ") ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: readId(item) || undefined,
|
id: readId(item) || undefined,
|
||||||
name: readString(item, "name", "Name") || undefined,
|
name: readString(item, "name", "Name") || undefined,
|
||||||
address: readString(item, "address", "Address") || undefined,
|
address: address || undefined,
|
||||||
city: readString(item, "city", "City") || undefined,
|
city: readString(item, "city", "City") || undefined,
|
||||||
state: readString(item, "state", "State") || undefined,
|
state: readString(item, "state", "State") || undefined,
|
||||||
zipCode: readString(item, "zipCode", "ZipCode", "Zip") || undefined,
|
zipCode: readString(item, "zipCode", "ZipCode", "Zip") || undefined,
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,14 @@ export interface Location {
|
||||||
accountId?: string | number;
|
accountId?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Site option from GET locations/sites (wizard / board create). */
|
||||||
|
export interface LocationSite {
|
||||||
|
id: string | number;
|
||||||
|
code: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LocationsListParams {
|
export interface LocationsListParams {
|
||||||
search?: string;
|
search?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|
|
||||||
23
src/domain/locations/use-cases/use-location-sites.ts
Normal file
23
src/domain/locations/use-cases/use-location-sites.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||||
|
import { locationsApi } from "@/domain/locations/api/locations-api";
|
||||||
|
import { formatLocationSiteLabel } from "@/domain/locations/mappers/location-mapper";
|
||||||
|
import type { LocationOption } from "@/domain/work-orders/types/work-order-common";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
/** Sites for WO board wizard (`GET locations/sites`), mapped to LocationOption. */
|
||||||
|
export function useLocationSites(search = ""): UseQueryResult<LocationOption[], Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.locations.sites(search),
|
||||||
|
queryFn: async () => {
|
||||||
|
const sites = await locationsApi.getSites(search || undefined);
|
||||||
|
return sites.map((site): LocationOption => ({
|
||||||
|
id: site.id,
|
||||||
|
name: formatLocationSiteLabel(site),
|
||||||
|
siteCode: site.code,
|
||||||
|
city: site.city,
|
||||||
|
state: site.state,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
3
src/domain/vendors/api/vendors-api.ts
vendored
3
src/domain/vendors/api/vendors-api.ts
vendored
|
|
@ -8,6 +8,7 @@ import {
|
||||||
mapVendorListItems,
|
mapVendorListItems,
|
||||||
mapVendorPortalToken,
|
mapVendorPortalToken,
|
||||||
mapVendorToBackend,
|
mapVendorToBackend,
|
||||||
|
mapVendorUpdateToBackend,
|
||||||
} from "@/domain/vendors/mappers/vendor-mapper";
|
} from "@/domain/vendors/mappers/vendor-mapper";
|
||||||
import type {
|
import type {
|
||||||
Vendor,
|
Vendor,
|
||||||
|
|
@ -95,7 +96,7 @@ export const vendorsApi = {
|
||||||
update: async (id: string | number, payload: unknown): Promise<Vendor> => {
|
update: async (id: string | number, payload: unknown): Promise<Vendor> => {
|
||||||
const data = await apiPut<unknown>(
|
const data = await apiPut<unknown>(
|
||||||
`${API_PATHS.rest.vendors}/${id}`,
|
`${API_PATHS.rest.vendors}/${id}`,
|
||||||
mapVendorToBackend(payload),
|
mapVendorUpdateToBackend(payload),
|
||||||
);
|
);
|
||||||
return mapVendor(handleApiResponse(data));
|
return mapVendor(handleApiResponse(data));
|
||||||
},
|
},
|
||||||
|
|
|
||||||
43
src/domain/vendors/mappers/vendor-mapper.ts
vendored
43
src/domain/vendors/mappers/vendor-mapper.ts
vendored
|
|
@ -172,6 +172,49 @@ export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mapVendorUpdateToBackend(vendor: unknown): Record<string, unknown> {
|
||||||
|
const item = asRecord(vendor);
|
||||||
|
const payload: Record<string, unknown> = {};
|
||||||
|
const fieldMappings = [
|
||||||
|
["companyName", "CompanyName"],
|
||||||
|
["contactName", "ContactName"],
|
||||||
|
["email", "Email"],
|
||||||
|
["googleMapsUrl", "GoogleMapsUrl"],
|
||||||
|
["notes", "Notes"],
|
||||||
|
["address", "Address"],
|
||||||
|
["city", "City"],
|
||||||
|
["state", "State"],
|
||||||
|
["zip", "Zip"],
|
||||||
|
["tradeSpecialties", "TradeSpecialties"],
|
||||||
|
["isActive", "IsActive"],
|
||||||
|
["preferredContact", "PreferredContact"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
fieldMappings.forEach(([target, source]) => {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(item, target)) payload[target] = item[target];
|
||||||
|
if (Object.prototype.hasOwnProperty.call(item, source)) payload[target] = item[source];
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
Object.prototype.hasOwnProperty.call(item, "companyId") ||
|
||||||
|
Object.prototype.hasOwnProperty.call(item, "CompanyId")
|
||||||
|
) {
|
||||||
|
payload.companyId = readNumericCompanyId(item);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Object.prototype.hasOwnProperty.call(item, "phone") ||
|
||||||
|
Object.prototype.hasOwnProperty.call(item, "Phone")
|
||||||
|
) {
|
||||||
|
payload.phone = toCanonicalPhone(item.phone ?? item.Phone);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Object.prototype.hasOwnProperty.call(item, "companyPhone") ||
|
||||||
|
Object.prototype.hasOwnProperty.call(item, "CompanyPhone")
|
||||||
|
) {
|
||||||
|
payload.companyPhone = toCanonicalPhone(item.companyPhone ?? item.CompanyPhone);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
function mapFacetCompany(raw: unknown): VendorFacetCompany {
|
function mapFacetCompany(raw: unknown): VendorFacetCompany {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
39
src/domain/vendors/schemas/vendor-schema.ts
vendored
39
src/domain/vendors/schemas/vendor-schema.ts
vendored
|
|
@ -40,3 +40,42 @@ export const vendorSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export type VendorFormValues = z.infer<typeof vendorSchema>;
|
export type VendorFormValues = z.infer<typeof vendorSchema>;
|
||||||
|
|
||||||
|
export const vendorUpdateSchema = vendorSchema.partial();
|
||||||
|
|
||||||
|
export type VendorUpdateValues = z.infer<typeof vendorUpdateSchema>;
|
||||||
|
|
||||||
|
const VENDOR_UPDATE_FIELDS = [
|
||||||
|
"companyName",
|
||||||
|
"contactName",
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"companyPhone",
|
||||||
|
"googleMapsUrl",
|
||||||
|
"notes",
|
||||||
|
"address",
|
||||||
|
"city",
|
||||||
|
"state",
|
||||||
|
"zip",
|
||||||
|
"tradeSpecialties",
|
||||||
|
"isActive",
|
||||||
|
"companyId",
|
||||||
|
"preferredContact",
|
||||||
|
] as const satisfies readonly (keyof VendorFormValues)[];
|
||||||
|
|
||||||
|
export function getChangedVendorValues(
|
||||||
|
values: Partial<VendorFormValues>,
|
||||||
|
original: VendorFormValues | null,
|
||||||
|
): VendorUpdateValues {
|
||||||
|
if (!original) return {};
|
||||||
|
|
||||||
|
return VENDOR_UPDATE_FIELDS.reduce<VendorUpdateValues>((changed, field) => {
|
||||||
|
if (
|
||||||
|
Object.prototype.hasOwnProperty.call(values, field) &&
|
||||||
|
!Object.is(values[field], original[field])
|
||||||
|
) {
|
||||||
|
Object.assign(changed, { [field]: values[field] });
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
import type { VendorUpdateValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
import type { Vendor } from "@/domain/vendors/types/vendor";
|
import type { Vendor } from "@/domain/vendors/types/vendor";
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
interface UpdateVendorInput {
|
export interface UpdateVendorInput {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
values: VendorFormValues;
|
values: VendorUpdateValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateVendor(): UseMutationResult<Vendor, Error, UpdateVendorInput> {
|
export function useUpdateVendor(): UseMutationResult<Vendor, Error, UpdateVendorInput> {
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,12 @@
|
||||||
import { API_PATHS } from "@/api/api-paths";
|
import { API_PATHS } from "@/api/api-paths";
|
||||||
import { api, apiDelete, apiGet, apiPost, apiPut, mapHttpStatusToMessage } from "@/api/api";
|
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
|
||||||
|
import { api, apiDelete, apiGet, apiPost, apiPostForm, apiPut } from "@/api/api";
|
||||||
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
||||||
|
import {
|
||||||
|
BoardPatchConflictError,
|
||||||
|
BoardPatchValidationError,
|
||||||
|
} from "@/domain/work-orders/errors/board-patch-errors";
|
||||||
import { workOrderBoardDocumentsApi } from "@/domain/work-orders/api/work-order-board-documents-api";
|
import { workOrderBoardDocumentsApi } from "@/domain/work-orders/api/work-order-board-documents-api";
|
||||||
import {
|
|
||||||
isWoTableMockEnabled,
|
|
||||||
shouldUseMockWorkOrderDetail,
|
|
||||||
} from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
|
|
||||||
import {
|
|
||||||
buildMockWorkOrderDetailFromTableRow,
|
|
||||||
getMockWorkOrderLocations,
|
|
||||||
getMockWorkOrderTableRow,
|
|
||||||
getMockWorkOrderVendors,
|
|
||||||
getMockWorkOrdersList,
|
|
||||||
MOCK_WO_TABLE_USERS,
|
|
||||||
} from "@/domain/work-orders/mocks/wo-table-mock-data";
|
|
||||||
import { mapWorkOrderBoardDetail } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
|
||||||
import {
|
import {
|
||||||
mapDispatchComment,
|
mapDispatchComment,
|
||||||
mapDispatchDetail,
|
mapDispatchDetail,
|
||||||
|
|
@ -25,12 +17,14 @@ import {
|
||||||
mapTaskListTemplates,
|
mapTaskListTemplates,
|
||||||
mapUpdateDispatchPayload,
|
mapUpdateDispatchPayload,
|
||||||
mapVendorDropdownItems,
|
mapVendorDropdownItems,
|
||||||
mapWorkOrderComment,
|
|
||||||
mapWorkOrderDetail,
|
mapWorkOrderDetail,
|
||||||
mapWorkOrderFormToCreatePayload,
|
mapWorkOrderFormToCreatePayload,
|
||||||
mapWorkOrderFormToUpdatePayload,
|
mapWorkOrderFormToUpdateFormData,
|
||||||
|
mapWorkOrderTableRow,
|
||||||
mapWorkOrderTableRows,
|
mapWorkOrderTableRows,
|
||||||
} from "@/domain/work-orders/mappers/work-order-mapper";
|
} from "@/domain/work-orders/mappers/work-order-mapper";
|
||||||
|
import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
|
import { mapWorkOrderBoardDetail } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
|
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
|
||||||
import type {
|
import type {
|
||||||
DispatchDetail,
|
DispatchDetail,
|
||||||
|
|
@ -41,32 +35,26 @@ import type {
|
||||||
UpdateDispatchPayload,
|
UpdateDispatchPayload,
|
||||||
WorkOrderVendorUpdatePayload,
|
WorkOrderVendorUpdatePayload,
|
||||||
VendorDropdownItem,
|
VendorDropdownItem,
|
||||||
WorkOrderComment,
|
|
||||||
WorkOrderDetail,
|
WorkOrderDetail,
|
||||||
WorkOrderFormValues,
|
WorkOrderFormValues,
|
||||||
WorkOrderUser,
|
WorkOrderUser,
|
||||||
} from "@/domain/work-orders/types/work-order";
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import type {
|
||||||
|
WorkOrderBoardListResult,
|
||||||
|
WorkOrderBoardPatchPayload,
|
||||||
|
WorkOrderBoardQueryParams,
|
||||||
|
WorkOrderBoardResponseApi,
|
||||||
|
WorkOrderBoardSearchQueryParams,
|
||||||
|
} from "@/domain/work-orders/types/work-order-board";
|
||||||
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import {
|
import {
|
||||||
DISPATCH_LOCATION_WO_PAGE_SIZE,
|
DISPATCH_LOCATION_WO_PAGE_SIZE,
|
||||||
type WorkOrdersListParams,
|
type WorkOrdersListParams,
|
||||||
type WorkOrdersListResult,
|
type WorkOrdersListResult,
|
||||||
} from "@/domain/work-orders/types/work-order-list";
|
} from "@/domain/work-orders/types/work-order-list";
|
||||||
import type { WorkOrderBoardPatchPayload } from "@/domain/work-orders/types/work-order-board-patch";
|
import { buildBoardSearchParamsRecord } from "@/domain/work-orders/utils/board-query-params";
|
||||||
|
|
||||||
function asRecord(raw: unknown): Record<string, unknown> {
|
|
||||||
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function readVersionField(record: Record<string, unknown>): string {
|
|
||||||
for (const key of ["rowVersion", "RowVersion", "workOrderVersion", "WorkOrderVersion"]) {
|
|
||||||
const value = record[key];
|
|
||||||
if (typeof value === "string" && value.length > 0) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSearchParams(params: WorkOrdersListParams): Record<string, string | number> {
|
function buildSearchParams(params: WorkOrdersListParams): Record<string, string | number> {
|
||||||
const searchParams: Record<string, string | number> = {
|
const searchParams: Record<string, string | number> = {
|
||||||
|
|
@ -106,6 +94,22 @@ function buildSearchParams(params: WorkOrdersListParams): Record<string, string
|
||||||
return searchParams;
|
return searchParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toUrlSearchParams(
|
||||||
|
params: Record<string, string | number | boolean | (string | number)[]>,
|
||||||
|
): URLSearchParams {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const entry of value) {
|
||||||
|
sp.append(key, String(entry));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sp.append(key, String(value));
|
||||||
|
}
|
||||||
|
return sp;
|
||||||
|
}
|
||||||
|
|
||||||
function mapUser(raw: unknown): WorkOrderUser {
|
function mapUser(raw: unknown): WorkOrderUser {
|
||||||
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||||
const id = item.id ?? item.Id;
|
const id = item.id ?? item.Id;
|
||||||
|
|
@ -121,19 +125,10 @@ function mapUser(raw: unknown): WorkOrderUser {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mockNetworkDelay(): Promise<void> {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const workOrdersApi = {
|
export const workOrdersApi = {
|
||||||
...workOrderBoardDocumentsApi,
|
...workOrderBoardDocumentsApi,
|
||||||
|
|
||||||
getList: async (params: WorkOrdersListParams = {}): Promise<WorkOrdersListResult> => {
|
getList: async (params: WorkOrdersListParams = {}): Promise<WorkOrdersListResult> => {
|
||||||
if (isWoTableMockEnabled()) {
|
|
||||||
await mockNetworkDelay();
|
|
||||||
return getMockWorkOrdersList(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchParams = buildSearchParams(params);
|
const searchParams = buildSearchParams(params);
|
||||||
const data = await apiGet<unknown>(API_PATHS.workOrder.getList, { searchParams });
|
const data = await apiGet<unknown>(API_PATHS.workOrder.getList, { searchParams });
|
||||||
const paginated = handlePaginatedResponse<unknown>(data, {
|
const paginated = handlePaginatedResponse<unknown>(data, {
|
||||||
|
|
@ -149,6 +144,53 @@ export const workOrdersApi = {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getBoard: async (params: WorkOrderBoardQueryParams): Promise<WorkOrderBoardListResult> => {
|
||||||
|
const searchParams = toUrlSearchParams(buildBoardSearchParamsRecord(params));
|
||||||
|
const data = await apiGet<unknown>(API_PATHS.workOrder.board, { searchParams });
|
||||||
|
const payload = (
|
||||||
|
typeof data === "object" && data !== null ? data : {}
|
||||||
|
) as WorkOrderBoardResponseApi;
|
||||||
|
|
||||||
|
const scheduled = mapWorkOrderTableRows(payload.scheduled ?? []);
|
||||||
|
const unscheduled = mapWorkOrderTableRows(payload.unscheduled ?? []);
|
||||||
|
const returnedCount = payload.counts?.returned ?? scheduled.length;
|
||||||
|
const totalCount = payload.counts?.total ?? returnedCount;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: [...unscheduled, ...scheduled],
|
||||||
|
scheduled,
|
||||||
|
unscheduled,
|
||||||
|
totalCount,
|
||||||
|
returnedCount,
|
||||||
|
page: 1,
|
||||||
|
pageSize: scheduled.length + unscheduled.length,
|
||||||
|
weekStart: payload.weekStart,
|
||||||
|
weekEnd: payload.weekEnd,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
searchBoard: async (
|
||||||
|
params: WorkOrderBoardSearchQueryParams,
|
||||||
|
): Promise<WorkOrderBoardListResult> => {
|
||||||
|
const searchParams = toUrlSearchParams(buildBoardSearchParamsRecord(params));
|
||||||
|
const data = await apiGet<unknown>(API_PATHS.workOrder.boardSearch, { searchParams });
|
||||||
|
const paginated = handlePaginatedResponse<unknown>(data, {
|
||||||
|
page: params.page,
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
});
|
||||||
|
const items = mapWorkOrderTableRows(paginated.items);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
scheduled: items.filter((row) => !!row.scheduledOn),
|
||||||
|
unscheduled: items.filter((row) => !row.scheduledOn),
|
||||||
|
totalCount: paginated.totalCount,
|
||||||
|
returnedCount: items.length,
|
||||||
|
page: paginated.page,
|
||||||
|
pageSize: paginated.pageSize,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
getListByLocation: async (
|
getListByLocation: async (
|
||||||
locationId: string | number,
|
locationId: string | number,
|
||||||
search = "",
|
search = "",
|
||||||
|
|
@ -164,23 +206,27 @@ export const workOrdersApi = {
|
||||||
},
|
},
|
||||||
|
|
||||||
fetchUsers: async (): Promise<WorkOrderUser[]> => {
|
fetchUsers: async (): Promise<WorkOrderUser[]> => {
|
||||||
if (isWoTableMockEnabled()) {
|
|
||||||
return MOCK_WO_TABLE_USERS;
|
|
||||||
}
|
|
||||||
const data = await apiGet<unknown>(API_PATHS.user.list);
|
const data = await apiGet<unknown>(API_PATHS.user.list);
|
||||||
const users = handleApiResponse<unknown[]>(data);
|
const users = handleApiResponse<unknown[]>(data);
|
||||||
return Array.isArray(users) ? users.map(mapUser) : [];
|
return Array.isArray(users) ? users.map(mapUser) : [];
|
||||||
},
|
},
|
||||||
|
|
||||||
getById: async (id: string | number): Promise<WorkOrderDetail> => {
|
fetchDispatchers: async (): Promise<WorkOrderUser[]> => {
|
||||||
if (shouldUseMockWorkOrderDetail(id)) {
|
try {
|
||||||
await mockNetworkDelay();
|
const data = await apiGet<unknown>(API_PATHS.workOrder.lookupsDispatchers);
|
||||||
const row = getMockWorkOrderTableRow(id);
|
const users = handleApiResponse<unknown[]>(data);
|
||||||
if (!row) {
|
const mapped = Array.isArray(users) ? users.map(mapUser) : [];
|
||||||
throw new Error("Mock work order not found");
|
if (mapped.length > 0) {
|
||||||
|
return mapped;
|
||||||
}
|
}
|
||||||
return buildMockWorkOrderDetailFromTableRow(row);
|
} catch {
|
||||||
|
// Fall back to User list when lookup is unavailable.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return workOrdersApi.fetchUsers();
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string | number): Promise<WorkOrderDetail> => {
|
||||||
const data = await apiGet<unknown>(API_PATHS.workOrder.getById, {
|
const data = await apiGet<unknown>(API_PATHS.workOrder.getById, {
|
||||||
searchParams: { id },
|
searchParams: { id },
|
||||||
});
|
});
|
||||||
|
|
@ -200,34 +246,16 @@ export const workOrdersApi = {
|
||||||
return mapWorkOrderDetail(handleApiResponse(data));
|
return mapWorkOrderDetail(handleApiResponse(data));
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
createFromBoard: async (draft: WorkOrderWizardDraft): Promise<WorkOrderDetail> => {
|
||||||
* Reads concurrency token from board detail for PATCH …/board.
|
const payload = mapWizardDraftToBoardCreatePayload(draft);
|
||||||
* CompDoc docStatus persists on that contract, which requires workOrderVersion.
|
const data = await apiPost<unknown>(API_PATHS.workOrder.boardCreate, payload);
|
||||||
*/
|
return mapWorkOrderDetail(handleApiResponse(data));
|
||||||
resolveBoardWorkOrderVersion: async (id: string | number): Promise<string> => {
|
|
||||||
if (shouldUseMockWorkOrderDetail(id) || isWoTableMockEnabled()) {
|
|
||||||
return `mock-version-${id}`;
|
|
||||||
}
|
|
||||||
const data = await apiGet<unknown>(API_PATHS.workOrder.boardDetail(id));
|
|
||||||
const root = asRecord(handleApiResponse(data) ?? data);
|
|
||||||
const info = asRecord(root.info ?? root.Info ?? root);
|
|
||||||
const version = readVersionField(info) || readVersionField(root);
|
|
||||||
if (!version) {
|
|
||||||
throw new Error("Missing work order version for board patch. Refresh and retry.");
|
|
||||||
}
|
|
||||||
return version;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Persist a single board-editable field (e.g. docStatus Yes|No|NotNeeded). */
|
|
||||||
patchBoardField: async (
|
patchBoardField: async (
|
||||||
id: string | number,
|
id: string | number,
|
||||||
payload: WorkOrderBoardPatchPayload,
|
payload: WorkOrderBoardPatchPayload,
|
||||||
): Promise<void> => {
|
): Promise<WorkOrderTableRow> => {
|
||||||
if (shouldUseMockWorkOrderDetail(id) || isWoTableMockEnabled()) {
|
|
||||||
await mockNetworkDelay();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await api.patch(API_PATHS.workOrder.boardPatch(id), {
|
const response = await api.patch(API_PATHS.workOrder.boardPatch(id), {
|
||||||
json: payload,
|
json: payload,
|
||||||
throwHttpErrors: false,
|
throwHttpErrors: false,
|
||||||
|
|
@ -241,35 +269,42 @@ export const workOrdersApi = {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return;
|
return mapWorkOrderTableRow(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(mapHttpStatusToMessage(response.status, data));
|
const record =
|
||||||
|
typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
|
||||||
|
|
||||||
|
if (response.status === 409) {
|
||||||
|
const currentStateRaw = record.currentState ?? record.CurrentState;
|
||||||
|
const currentState = currentStateRaw != null ? mapWorkOrderTableRow(currentStateRaw) : null;
|
||||||
|
throw new BoardPatchConflictError(
|
||||||
|
typeof record.message === "string"
|
||||||
|
? record.message
|
||||||
|
: "The record was modified by another user. Refresh and retry.",
|
||||||
|
currentState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 422) {
|
||||||
|
throw new BoardPatchValidationError(
|
||||||
|
typeof record.code === "string" ? record.code : "ValidationError",
|
||||||
|
typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
|
||||||
},
|
},
|
||||||
|
|
||||||
update: async (
|
update: async (
|
||||||
id: string | number,
|
id: string | number,
|
||||||
values: WorkOrderFormSchemaValues | WorkOrderFormValues,
|
values: WorkOrderFormSchemaValues | WorkOrderFormValues,
|
||||||
): Promise<WorkOrderDetail> => {
|
): Promise<void> => {
|
||||||
if (shouldUseMockWorkOrderDetail(id)) {
|
const formData = mapWorkOrderFormToUpdateFormData(id, values);
|
||||||
await mockNetworkDelay();
|
await apiPostForm<{ message?: string; status?: string }>(
|
||||||
const row = getMockWorkOrderTableRow(id);
|
API_PATHS.workOrder.editWorkorder,
|
||||||
if (!row) {
|
formData,
|
||||||
throw new Error("Mock work order not found");
|
);
|
||||||
}
|
|
||||||
return buildMockWorkOrderDetailFromTableRow({
|
|
||||||
...row,
|
|
||||||
woNumber: values.workOrderNumber || row.woNumber,
|
|
||||||
dueDate: values.dueDate || row.dueDate,
|
|
||||||
scheduledOn: values.scheduledDate || row.scheduledOn,
|
|
||||||
dispatcherName: values.assignedTo || row.dispatcherName,
|
|
||||||
pm: values.problem || row.pm,
|
|
||||||
type: (values.source || row.type) as typeof row.type,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const payload = mapWorkOrderFormToUpdatePayload(id, values);
|
|
||||||
const data = await apiPost<unknown>(API_PATHS.workOrder.editWorkorder, payload);
|
|
||||||
return mapWorkOrderDetail(handleApiResponse(data));
|
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: async (id: string | number): Promise<void> => {
|
delete: async (id: string | number): Promise<void> => {
|
||||||
|
|
@ -278,43 +313,19 @@ export const workOrdersApi = {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
addComment: async (
|
|
||||||
workOrderId: string | number,
|
|
||||||
text: string,
|
|
||||||
commentType: string,
|
|
||||||
): Promise<WorkOrderComment> => {
|
|
||||||
const data = await apiPost<unknown>(API_PATHS.workOrder.addCommentJson, {
|
|
||||||
WorkorderId: workOrderId,
|
|
||||||
Text: text,
|
|
||||||
CommentType: commentType,
|
|
||||||
});
|
|
||||||
return mapWorkOrderComment(handleApiResponse(data));
|
|
||||||
},
|
|
||||||
|
|
||||||
changeStatus: async (id: string | number, status: string): Promise<void> => {
|
changeStatus: async (id: string | number, status: string): Promise<void> => {
|
||||||
if (shouldUseMockWorkOrderDetail(id)) {
|
|
||||||
await mockNetworkDelay();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await apiPost<unknown>(API_PATHS.workOrder.changeStatus, undefined, {
|
await apiPost<unknown>(API_PATHS.workOrder.changeStatus, undefined, {
|
||||||
searchParams: { id, status },
|
searchParams: { id, status },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
changeAssignment: async (id: string | number, assignTo: string | number): Promise<void> => {
|
changeAssignment: async (id: string | number, assignTo: string | number): Promise<void> => {
|
||||||
if (shouldUseMockWorkOrderDetail(id)) {
|
|
||||||
await mockNetworkDelay();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await apiPost<unknown>(API_PATHS.workOrder.changeAssignment, undefined, {
|
await apiPost<unknown>(API_PATHS.workOrder.changeAssignment, undefined, {
|
||||||
searchParams: { id, assignTo: assignTo || "" },
|
searchParams: { id, assignTo: assignTo || "" },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
fetchLocations: async (): Promise<LocationOption[]> => {
|
fetchLocations: async (): Promise<LocationOption[]> => {
|
||||||
if (isWoTableMockEnabled()) {
|
|
||||||
return getMockWorkOrderLocations();
|
|
||||||
}
|
|
||||||
const data = await apiGet<unknown>(API_PATHS.contact.getLocations);
|
const data = await apiGet<unknown>(API_PATHS.contact.getLocations);
|
||||||
const items = handleApiResponse<unknown[]>(data);
|
const items = handleApiResponse<unknown[]>(data);
|
||||||
return Array.isArray(items) ? mapLocationOptions(items) : [];
|
return Array.isArray(items) ? mapLocationOptions(items) : [];
|
||||||
|
|
@ -327,9 +338,6 @@ export const workOrdersApi = {
|
||||||
},
|
},
|
||||||
|
|
||||||
fetchVendorsDropdown: async (trade?: string, siteZip?: string): Promise<VendorDropdownItem[]> => {
|
fetchVendorsDropdown: async (trade?: string, siteZip?: string): Promise<VendorDropdownItem[]> => {
|
||||||
if (isWoTableMockEnabled()) {
|
|
||||||
return getMockWorkOrderVendors();
|
|
||||||
}
|
|
||||||
const searchParams: Record<string, string> = {};
|
const searchParams: Record<string, string> = {};
|
||||||
if (trade) {
|
if (trade) {
|
||||||
searchParams.trade = trade;
|
searchParams.trade = trade;
|
||||||
|
|
@ -406,19 +414,16 @@ export const workOrdersApi = {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
addDispatchSignoff: async (params: {
|
addDispatchSignoff: async (
|
||||||
dispatchId: string | number;
|
dispatchId: string | number,
|
||||||
signoffType: string;
|
options: { signoffType: string; name: string; signature: string; signatureMethod: string },
|
||||||
name: string;
|
): Promise<DispatchDetail["signoffs"][number]> => {
|
||||||
signature: string;
|
|
||||||
signatureMethod: string;
|
|
||||||
}): Promise<DispatchDetail["signoffs"][number]> => {
|
|
||||||
const data = await apiPost<unknown>(API_PATHS.workOrder.addDispatchSignoff, {
|
const data = await apiPost<unknown>(API_PATHS.workOrder.addDispatchSignoff, {
|
||||||
DispatchId: params.dispatchId,
|
DispatchId: dispatchId,
|
||||||
SignoffType: params.signoffType,
|
SignoffType: options.signoffType,
|
||||||
Name: params.name,
|
Name: options.name,
|
||||||
Signature: params.signature,
|
Signature: options.signature,
|
||||||
SignatureMethod: params.signatureMethod,
|
SignatureMethod: options.signatureMethod,
|
||||||
});
|
});
|
||||||
return mapDispatchSignoff(handleApiResponse(data));
|
return mapDispatchSignoff(handleApiResponse(data));
|
||||||
},
|
},
|
||||||
|
|
|
||||||
13
src/domain/work-orders/flag-colors.ts
Normal file
13
src/domain/work-orders/flag-colors.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/** Fixed board flag palette — must match BE `WorkOrderFlagColors`. */
|
||||||
|
export const FLAG_COLORS: { name: string; value: string }[] = [
|
||||||
|
{ name: "Red", value: "#EF4444" },
|
||||||
|
{ name: "Orange", value: "#F97316" },
|
||||||
|
{ name: "Amber", value: "#F59E0B" },
|
||||||
|
{ name: "Yellow", value: "#EAB308" },
|
||||||
|
{ name: "Green", value: "#22C55E" },
|
||||||
|
{ name: "Teal", value: "#14B8A6" },
|
||||||
|
{ name: "Blue", value: "#3B82F6" },
|
||||||
|
{ name: "Purple", value: "#8B5CF6" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FLAG_COLOR_VALUES = FLAG_COLORS.map((c) => c.value);
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
||||||
|
/** Mutates `payload` with `dueDate` and day/week schedule fields (range mode has no schedule fields). */
|
||||||
|
export function assignWizardScheduleFields(
|
||||||
|
payload: WorkOrderBoardCreatePayload,
|
||||||
|
draft: WorkOrderWizardDraft,
|
||||||
|
): void {
|
||||||
|
if (draft.dueDate) {
|
||||||
|
payload.dueDate = draft.dueDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRange = draft.schedMode === "range";
|
||||||
|
|
||||||
|
if (!isRange && draft.scheduleWeekOnly) {
|
||||||
|
payload.scheduleWeekOnly = true;
|
||||||
|
|
||||||
|
const week = draft.targetWeek?.trim() || (draft.scheduledOn ? mondayOf(draft.scheduledOn) : "");
|
||||||
|
|
||||||
|
if (week) {
|
||||||
|
payload.targetWeek = week;
|
||||||
|
}
|
||||||
|
} else if (!isRange && draft.scheduledOn) {
|
||||||
|
payload.scheduledDate = draft.scheduledOn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
function trimOrUndefined(value: string | undefined): string | undefined {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
|
||||||
|
return trimmed ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExtraServices(
|
||||||
|
primary: string | undefined,
|
||||||
|
extras: string[],
|
||||||
|
): string[] | undefined {
|
||||||
|
if (extras.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const primaryKey = primary?.toLowerCase() ?? "";
|
||||||
|
|
||||||
|
const out: string[] = [];
|
||||||
|
|
||||||
|
for (const raw of extras) {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = trimmed.toLowerCase();
|
||||||
|
|
||||||
|
if (key === primaryKey || seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(key);
|
||||||
|
|
||||||
|
out.push(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.length > 0 ? out : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mutates `payload` with primary/extra services, service notes, POC, tech phone, and vendor notes. */
|
||||||
|
export function assignWizardServicePocFields(
|
||||||
|
payload: WorkOrderBoardCreatePayload,
|
||||||
|
draft: WorkOrderWizardDraft,
|
||||||
|
): void {
|
||||||
|
const primaryService = trimOrUndefined(draft.pm);
|
||||||
|
|
||||||
|
if (primaryService) {
|
||||||
|
payload.primaryService = primaryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraServices = normalizeExtraServices(primaryService, draft.extraServices);
|
||||||
|
|
||||||
|
if (extraServices) {
|
||||||
|
payload.extraServices = extraServices;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceNotes = trimOrUndefined(draft.pmNote);
|
||||||
|
|
||||||
|
if (serviceNotes) {
|
||||||
|
payload.serviceNotes = serviceNotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pocName = trimOrUndefined(draft.pocName);
|
||||||
|
|
||||||
|
if (pocName) {
|
||||||
|
payload.pocName = pocName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pocPhone = trimOrUndefined(draft.pocPhone);
|
||||||
|
|
||||||
|
if (pocPhone) {
|
||||||
|
payload.pocPhone = pocPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pocNotes = trimOrUndefined(draft.pocNotes);
|
||||||
|
|
||||||
|
if (pocNotes) {
|
||||||
|
payload.pocNotes = pocNotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const techPhone = trimOrUndefined(draft.techPhone);
|
||||||
|
|
||||||
|
if (techPhone) {
|
||||||
|
payload.techPhone = techPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendorNotes = trimOrUndefined(draft.vendorNotes);
|
||||||
|
|
||||||
|
if (vendorNotes) {
|
||||||
|
payload.vendorNotes = vendorNotes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { formatApptTimeValue } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
||||||
|
/** Mutates `payload` with `vendorId` and, when applicable, single-day appt date/time. */
|
||||||
|
export function assignWizardVendorApptFields(
|
||||||
|
payload: WorkOrderBoardCreatePayload,
|
||||||
|
draft: WorkOrderWizardDraft,
|
||||||
|
): void {
|
||||||
|
const vendorId = draft.vendorId.trim() ? Number(draft.vendorId) : NaN;
|
||||||
|
const hasVendor = Number.isFinite(vendorId);
|
||||||
|
|
||||||
|
if (hasVendor) {
|
||||||
|
payload.vendorId = vendorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apptTime = formatApptTimeValue(draft.apptStart, draft.apptEnd);
|
||||||
|
const isRange = draft.schedMode === "range";
|
||||||
|
|
||||||
|
if (hasVendor && (draft.scheduledOn || apptTime) && !draft.scheduleWeekOnly && !isRange) {
|
||||||
|
if (draft.scheduledOn) {
|
||||||
|
payload.apptDate = draft.scheduledOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apptTime) {
|
||||||
|
payload.apptTime = apptTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/domain/work-orders/mappers/expand-scalar-board-ops.ts
Normal file
49
src/domain/work-orders/mappers/expand-scalar-board-ops.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import type { WorkOrderBoardEditableField } from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
export type BoardFieldOp = {
|
||||||
|
field: WorkOrderBoardEditableField;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mapDocStatusToApi(value: string): string {
|
||||||
|
if (value === "NN") {
|
||||||
|
return "NotNeeded";
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScalarBoardOpRule = {
|
||||||
|
key: keyof WorkOrderTablePatch;
|
||||||
|
field: WorkOrderBoardEditableField;
|
||||||
|
toValue: (value: unknown) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One row per scalar patch field, in emission order. */
|
||||||
|
const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
|
||||||
|
{ key: "woNumber", field: "woNumber", toValue: (value) => value as string },
|
||||||
|
{ key: "type", field: "workOrderType", toValue: (value) => value as string },
|
||||||
|
{ key: "site", field: "siteCode", toValue: (value) => value as string },
|
||||||
|
{ key: "status", field: "lifecycleStatus", toValue: (value) => value as string },
|
||||||
|
{ key: "dispatcherId", field: "assignTo", toValue: (value) => value as string },
|
||||||
|
{ key: "dueDate", field: "dueDate", toValue: (value) => value as string },
|
||||||
|
{ key: "vendorId", field: "vendorId", toValue: (value) => String(value) },
|
||||||
|
{ key: "apptTime", field: "apptTime", toValue: (value) => value as string },
|
||||||
|
{ key: "docStatus", field: "docStatus", toValue: (value) => mapDocStatusToApi(value as string) },
|
||||||
|
{ key: "pm", field: "pm", toValue: (value) => value as string },
|
||||||
|
{ key: "extraServices", field: "extraServices", toValue: (value) => JSON.stringify(value) },
|
||||||
|
{ key: "serviceNotes", field: "serviceNotes", toValue: (value) => value as string },
|
||||||
|
{ key: "flagColor", field: "flagColor", toValue: (value) => (value as string | null) ?? "" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function expandScalarBoardOps(patch: WorkOrderTablePatch): BoardFieldOp[] {
|
||||||
|
const ops: BoardFieldOp[] = [];
|
||||||
|
|
||||||
|
for (const rule of SCALAR_BOARD_OP_RULES) {
|
||||||
|
const value = patch[rule.key];
|
||||||
|
if (value === undefined) continue;
|
||||||
|
ops.push({ field: rule.field, value: rule.toValue(value) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
42
src/domain/work-orders/mappers/expand-schedule-board-ops.ts
Normal file
42
src/domain/work-orders/mappers/expand-schedule-board-ops.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import type { BoardFieldOp } from "@/domain/work-orders/mappers/expand-scalar-board-ops";
|
||||||
|
import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
function hasScheduleRelatedPatch(patch: WorkOrderTablePatch): boolean {
|
||||||
|
return (
|
||||||
|
patch.scheduledOn !== undefined ||
|
||||||
|
patch.targetWeek !== undefined ||
|
||||||
|
patch.scheduleWeekOnly !== undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schedule/week-only ops in the stable order the board API expects. */
|
||||||
|
export function expandScheduleBoardOps(patch: WorkOrderTablePatch): BoardFieldOp[] {
|
||||||
|
if (!hasScheduleRelatedPatch(patch)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ops: BoardFieldOp[] = [];
|
||||||
|
|
||||||
|
if (patch.scheduleWeekOnly === true) {
|
||||||
|
ops.push({ field: "scheduleWeekOnly", value: "true" });
|
||||||
|
if (patch.targetWeek !== undefined) {
|
||||||
|
ops.push({ field: "targetWeek", value: patch.targetWeek });
|
||||||
|
}
|
||||||
|
if (patch.scheduledOn !== undefined) {
|
||||||
|
ops.push({ field: "scheduledDate", value: patch.scheduledOn });
|
||||||
|
}
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.scheduleWeekOnly === false) {
|
||||||
|
ops.push({ field: "scheduleWeekOnly", value: "false" });
|
||||||
|
}
|
||||||
|
if (patch.scheduledOn !== undefined) {
|
||||||
|
ops.push({ field: "scheduledDate", value: patch.scheduledOn });
|
||||||
|
}
|
||||||
|
if (patch.targetWeek !== undefined) {
|
||||||
|
ops.push({ field: "targetWeek", value: patch.targetWeek });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
|
|
@ -83,12 +83,14 @@ function mapMediaCategory(raw: unknown): BoardMediaCategory {
|
||||||
export function mapBoardDetailComment(raw: unknown): WorkOrderComment {
|
export function mapBoardDetailComment(raw: unknown): WorkOrderComment {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
const id = readNumber(item, "id");
|
const id = readNumber(item, "id");
|
||||||
|
const authorId = readString(item, "authorId", "AuthorId") || null;
|
||||||
return {
|
return {
|
||||||
id: id ?? (readString(item, "id") || `comment-${readString(item, "time")}`),
|
id: id ?? (readString(item, "id") || `comment-${readString(item, "time")}`),
|
||||||
createdDate: readString(item, "time", "createdDate", "CreatedDate"),
|
createdDate: readString(item, "time", "createdDate", "CreatedDate"),
|
||||||
commenttext: readString(item, "text", "commenttext", "Commenttext"),
|
commenttext: readString(item, "text", "commenttext", "Commenttext"),
|
||||||
commentType: readString(item, "commentType", "CommentType") || "internal",
|
commentType: readString(item, "commentType", "CommentType") || "internal",
|
||||||
userName: readString(item, "userName", "UserName", "authorName", "AuthorName"),
|
userName: readString(item, "userName", "UserName", "authorName", "AuthorName"),
|
||||||
|
authorId,
|
||||||
commenter: readString(item, "commenter", "Commenter") || null,
|
commenter: readString(item, "commenter", "Commenter") || null,
|
||||||
recordType: readString(item, "recordType", "RecordType") || null,
|
recordType: readString(item, "recordType", "RecordType") || null,
|
||||||
};
|
};
|
||||||
|
|
@ -98,6 +100,8 @@ export function mapBoardDetailAuditEntry(raw: unknown, index: number): WorkOrder
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
const time = readString(item, "time", "createdAt", "CreatedAt");
|
const time = readString(item, "time", "createdAt", "CreatedAt");
|
||||||
const typeRaw = readString(item, "type", "Type").toLowerCase();
|
const typeRaw = readString(item, "type", "Type").toLowerCase();
|
||||||
|
const type: "manual" | "system" = typeRaw === "system" ? "system" : "manual";
|
||||||
|
const dispatcherId = readString(item, "dispatcherId", "DispatcherId");
|
||||||
const userName = readString(item, "userName", "UserName");
|
const userName = readString(item, "userName", "UserName");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -107,7 +111,9 @@ export function mapBoardDetailAuditEntry(raw: unknown, index: number): WorkOrder
|
||||||
newValue: readString(item, "newValue", "NewValue"),
|
newValue: readString(item, "newValue", "NewValue"),
|
||||||
action: readString(item, "action", "Action"),
|
action: readString(item, "action", "Action"),
|
||||||
createdAt: time,
|
createdAt: time,
|
||||||
userName: typeRaw === "system" ? "System" : userName,
|
userName: type === "system" ? "System" : userName,
|
||||||
|
type,
|
||||||
|
dispatcherId: dispatcherId || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,24 +235,12 @@ export function boardMediaItemsToMediaFiles(items: WorkOrderBoardMediaItem[]): M
|
||||||
|
|
||||||
export function mapWorkOrderBoardDetail(raw: unknown): WorkOrderBoardDetail {
|
export function mapWorkOrderBoardDetail(raw: unknown): WorkOrderBoardDetail {
|
||||||
const root = asRecord(raw);
|
const root = asRecord(raw);
|
||||||
const infoRaw = asRecord(root.info ?? root.Info ?? root);
|
const infoRaw = root.info ?? root.Info ?? root;
|
||||||
const commentsRaw = root.comments ?? root.Comments;
|
const commentsRaw = root.comments ?? root.Comments;
|
||||||
const auditRaw = root.audit ?? root.Audit;
|
const auditRaw = root.audit ?? root.Audit;
|
||||||
const mediaRaw = root.media ?? root.Media;
|
const mediaRaw = root.media ?? root.Media;
|
||||||
const completionRaw = root.completion ?? root.Completion;
|
const completionRaw = root.completion ?? root.Completion;
|
||||||
|
|
||||||
// Normalize board-detail field aliases before the shared table-row mapper.
|
|
||||||
const normalizedInfo = {
|
|
||||||
...infoRaw,
|
|
||||||
site: infoRaw.site ?? infoRaw.Site ?? infoRaw.siteCode ?? infoRaw.SiteCode,
|
|
||||||
tech: infoRaw.tech ?? infoRaw.Tech ?? infoRaw.techName ?? infoRaw.TechName,
|
|
||||||
status:
|
|
||||||
infoRaw.status ??
|
|
||||||
infoRaw.Status ??
|
|
||||||
infoRaw.lifecycleStatusLabel ??
|
|
||||||
infoRaw.LifecycleStatusLabel,
|
|
||||||
};
|
|
||||||
|
|
||||||
const comments = Array.isArray(commentsRaw) ? commentsRaw.map(mapBoardDetailComment) : [];
|
const comments = Array.isArray(commentsRaw) ? commentsRaw.map(mapBoardDetailComment) : [];
|
||||||
const audit = Array.isArray(auditRaw)
|
const audit = Array.isArray(auditRaw)
|
||||||
? auditRaw.map((entry, index) => mapBoardDetailAuditEntry(entry, index))
|
? auditRaw.map((entry, index) => mapBoardDetailAuditEntry(entry, index))
|
||||||
|
|
@ -254,7 +248,7 @@ export function mapWorkOrderBoardDetail(raw: unknown): WorkOrderBoardDetail {
|
||||||
const media = Array.isArray(mediaRaw) ? mediaRaw.map(mapBoardDetailMediaItem) : [];
|
const media = Array.isArray(mediaRaw) ? mediaRaw.map(mapBoardDetailMediaItem) : [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
info: mapWorkOrderTableRow(normalizedInfo),
|
info: mapWorkOrderTableRow(infoRaw),
|
||||||
completion: mapCompletion(completionRaw ?? {}),
|
completion: mapCompletion(completionRaw ?? {}),
|
||||||
comments,
|
comments,
|
||||||
audit,
|
audit,
|
||||||
|
|
|
||||||
126
src/domain/work-orders/mappers/work-order-board-mapper.ts
Normal file
126
src/domain/work-orders/mappers/work-order-board-mapper.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import type {
|
||||||
|
WorkOrderBoardCreatePayload,
|
||||||
|
WorkOrderTypeApi,
|
||||||
|
} from "@/domain/work-orders/types/work-order-board";
|
||||||
|
|
||||||
|
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
|
||||||
|
|
||||||
|
import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
import { fmtDateRange } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
||||||
|
import { assignWizardScheduleFields } from "@/domain/work-orders/mappers/assign-wizard-schedule-fields";
|
||||||
|
import { assignWizardVendorApptFields } from "@/domain/work-orders/mappers/assign-wizard-vendor-appt-fields";
|
||||||
|
import { assignWizardServicePocFields } from "@/domain/work-orders/mappers/assign-wizard-service-poc-fields";
|
||||||
|
|
||||||
|
const WO_TYPE_TO_API: Record<WOType, WorkOrderTypeApi> = {
|
||||||
|
PM: WORK_ORDER_TYPE_API.PM,
|
||||||
|
|
||||||
|
Emergency: WORK_ORDER_TYPE_API.Emergency,
|
||||||
|
|
||||||
|
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
||||||
|
|
||||||
|
"Add-On": WORK_ORDER_TYPE_API.AddOn,
|
||||||
|
|
||||||
|
Overdue: WORK_ORDER_TYPE_API.Other,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Leading token of a location label, e.g. `BK5` from `BK5 · Dallas` or `CHI-1042` from `CHI-1042 — Chicago`. */
|
||||||
|
|
||||||
|
export function parseSiteCodeFromLocationName(name: string): string {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = trimmed.match(/^([A-Za-z0-9-]+)/);
|
||||||
|
|
||||||
|
return match?.[1] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSiteCode(option: {
|
||||||
|
siteCode?: string;
|
||||||
|
|
||||||
|
name?: string;
|
||||||
|
}): string {
|
||||||
|
if (option.siteCode?.trim()) {
|
||||||
|
return option.siteCode.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseSiteCodeFromLocationName(option.name ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapWizardTypeToApi(type: WOType | ""): WorkOrderTypeApi | null {
|
||||||
|
if (!type) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return WO_TYPE_TO_API[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
|
||||||
|
* Legacy/general description only.
|
||||||
|
|
||||||
|
* Service notes, extras, POC, and vendor notes are sent as structured fields.
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function buildBoardDescription(draft: WorkOrderWizardDraft): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (draft.schedMode === "range" && draft.scheduledOn && draft.scheduleEnd) {
|
||||||
|
parts.push(`Schedule range: ${fmtDateRange(draft.scheduledOn, draft.scheduleEnd)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapWizardDraftToBoardCreatePayload(
|
||||||
|
draft: WorkOrderWizardDraft,
|
||||||
|
): WorkOrderBoardCreatePayload {
|
||||||
|
const workOrderType = mapWizardTypeToApi(draft.type);
|
||||||
|
|
||||||
|
const siteCode = draft.siteCode.trim() || resolveSiteCode({ name: draft.locationName });
|
||||||
|
|
||||||
|
if (workOrderType === null) {
|
||||||
|
throw new Error("Work order type is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!siteCode) {
|
||||||
|
throw new Error("Site code is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: WorkOrderBoardCreatePayload = {
|
||||||
|
workOrderType,
|
||||||
|
|
||||||
|
siteCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (draft.woNumber.trim()) {
|
||||||
|
payload.woNumber = draft.woNumber.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draft.assigneeId.trim()) {
|
||||||
|
payload.assignTo = draft.assigneeId.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
assignWizardScheduleFields(payload, draft);
|
||||||
|
assignWizardVendorApptFields(payload, draft);
|
||||||
|
assignWizardServicePocFields(payload, draft);
|
||||||
|
|
||||||
|
const description = buildBoardDescription(draft);
|
||||||
|
|
||||||
|
if (description) {
|
||||||
|
payload.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationId = draft.locationId.trim() ? Number(draft.locationId) : NaN;
|
||||||
|
|
||||||
|
if (Number.isFinite(locationId)) {
|
||||||
|
payload.locationId = locationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import type {
|
||||||
|
WorkOrderBoardEditableField,
|
||||||
|
WorkOrderBoardPatchPayload,
|
||||||
|
} from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import type {
|
||||||
|
WorkOrderTablePatch,
|
||||||
|
WorkOrderTableRow,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import {
|
||||||
|
expandScalarBoardOps,
|
||||||
|
mapDocStatusToApi,
|
||||||
|
type BoardFieldOp,
|
||||||
|
} from "@/domain/work-orders/mappers/expand-scalar-board-ops";
|
||||||
|
import { expandScheduleBoardOps } from "@/domain/work-orders/mappers/expand-schedule-board-ops";
|
||||||
|
|
||||||
|
export type { BoardFieldOp };
|
||||||
|
export { mapDocStatusToApi };
|
||||||
|
|
||||||
|
const DISPATCH_FIELDS = new Set<WorkOrderBoardEditableField>(["vendorId", "apptDate", "apptTime"]);
|
||||||
|
|
||||||
|
const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
|
||||||
|
"mediaCount",
|
||||||
|
"dayGroup",
|
||||||
|
"dayLabel",
|
||||||
|
"dispatcherName",
|
||||||
|
"company",
|
||||||
|
"tech",
|
||||||
|
"techPhone",
|
||||||
|
"location",
|
||||||
|
"locationId",
|
||||||
|
"pocName",
|
||||||
|
"pocPhone",
|
||||||
|
"pocNotes",
|
||||||
|
"title",
|
||||||
|
"priority",
|
||||||
|
"apptStart",
|
||||||
|
"apptEnd",
|
||||||
|
"rescheduleCount",
|
||||||
|
"carriedOver",
|
||||||
|
"isPastDue",
|
||||||
|
"originalWeek",
|
||||||
|
"originalDate",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {
|
||||||
|
const keys = Object.keys(patch) as (keyof WorkOrderTablePatch)[];
|
||||||
|
return keys.length > 0 && keys.every((key) => LOCAL_ONLY_KEYS.has(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expands a UI table patch into ordered board field ops (one PATCH each).
|
||||||
|
* Schedule/week-only fields are emitted first in a stable order.
|
||||||
|
*/
|
||||||
|
export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardFieldOp[] {
|
||||||
|
if (isLocalOnlyTablePatch(patch)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...expandScheduleBoardOps(patch), ...expandScalarBoardOps(patch)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDispatchBoardField(field: WorkOrderBoardEditableField): boolean {
|
||||||
|
return DISPATCH_FIELDS.has(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBoardPatchPayload(
|
||||||
|
op: BoardFieldOp,
|
||||||
|
row: Pick<WorkOrderTableRow, "rowVersion" | "dispatchRowVersion" | "primaryDispatchId">,
|
||||||
|
): WorkOrderBoardPatchPayload {
|
||||||
|
if (!row.rowVersion) {
|
||||||
|
throw new Error("workOrderVersion is required for board patch");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: WorkOrderBoardPatchPayload = {
|
||||||
|
field: op.field,
|
||||||
|
value: op.value,
|
||||||
|
workOrderVersion: row.rowVersion,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDispatchBoardField(op.field)) {
|
||||||
|
if (row.dispatchRowVersion) {
|
||||||
|
payload.dispatchVersion = row.dispatchRowVersion;
|
||||||
|
}
|
||||||
|
if (row.primaryDispatchId != null) {
|
||||||
|
payload.primaryDispatchId = row.primaryDispatchId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
169
src/domain/work-orders/mappers/work-order-dispatch-mapper.ts
Normal file
169
src/domain/work-orders/mappers/work-order-dispatch-mapper.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import type {
|
||||||
|
DispatchChecklistItem,
|
||||||
|
DispatchComment,
|
||||||
|
DispatchDetail,
|
||||||
|
DispatchSignoff,
|
||||||
|
DispatchToVendorPayload,
|
||||||
|
DispatchWorkOrderRef,
|
||||||
|
UpdateDispatchPayload,
|
||||||
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
function asRecord(raw: unknown): Record<string, unknown> {
|
||||||
|
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readId(record: Record<string, unknown>): string | number {
|
||||||
|
const value = record.id ?? record.Id;
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(record: Record<string, unknown>, ...keys: string[]): number | null {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value !== "") {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapDispatchChecklistItem(raw: unknown): DispatchChecklistItem {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: readId(item),
|
||||||
|
itemText: readString(item, "itemText", "ItemText"),
|
||||||
|
isCompleted: readBool(item, "isCompleted", "IsCompleted"),
|
||||||
|
completedBy: readString(item, "completedBy", "CompletedBy") || null,
|
||||||
|
completedAt: readString(item, "completedAt", "CompletedAt") || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapDispatchSignoff(raw: unknown): DispatchSignoff {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: readId(item),
|
||||||
|
signoffType: readString(item, "signoffType", "SignoffType"),
|
||||||
|
name: readString(item, "name", "Name"),
|
||||||
|
signature: readString(item, "signature", "Signature"),
|
||||||
|
signatureMethod: readString(item, "signatureMethod", "SignatureMethod"),
|
||||||
|
signedAt: readString(item, "signedAt", "SignedAt"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapDispatchComment(raw: unknown): DispatchComment {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: readId(item),
|
||||||
|
createdDate: readString(item, "createdDate", "CreatedDate"),
|
||||||
|
commenttext: readString(item, "commenttext", "Commenttext", "text", "Text"),
|
||||||
|
userName: readString(item, "userName", "UserName"),
|
||||||
|
commenter: readString(item, "commenter", "Commenter") || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapDispatchWorkOrderRef(raw: unknown): DispatchWorkOrderRef {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: readId(item),
|
||||||
|
woNumber: readString(item, "woNumber", "WoNumber", "WONumber", "number", "Number"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapDispatchDetail(raw: unknown): DispatchDetail {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
const checklistItems = item.checklistItems ?? item.ChecklistItems;
|
||||||
|
const signoffs = item.signoffs ?? item.Signoffs;
|
||||||
|
const comments = item.comments ?? item.Comments;
|
||||||
|
const workOrders = item.workOrders ?? item.WorkOrders;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: readId(item),
|
||||||
|
dispatchNumber: readString(item, "dispatchNumber", "DispatchNumber"),
|
||||||
|
poNumber: readString(item, "poNumber", "PoNumber", "PONumber"),
|
||||||
|
vendorName: readString(item, "vendorName", "VendorName"),
|
||||||
|
vendorEmail: readString(item, "vendorEmail", "VendorEmail"),
|
||||||
|
woNumber: readString(item, "woNumber", "WoNumber"),
|
||||||
|
status: readString(item, "status", "Status"),
|
||||||
|
nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
|
||||||
|
scheduledDate: readString(item, "scheduledDate", "ScheduledDate"),
|
||||||
|
estimatedArrivalAt: readString(item, "estimatedArrivalAt", "EstimatedArrivalAt"),
|
||||||
|
etaManualOverride: readBool(item, "etaManualOverride", "EtaManualOverride"),
|
||||||
|
completedDate: readString(item, "completedDate", "CompletedDate"),
|
||||||
|
description: readString(item, "description", "Description"),
|
||||||
|
dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),
|
||||||
|
acknowledgedAt: readString(item, "acknowledgedAt", "AcknowledgedAt"),
|
||||||
|
verifiedAt: readString(item, "verifiedAt", "VerifiedAt"),
|
||||||
|
verifiedBy: readString(item, "verifiedBy", "VerifiedBy"),
|
||||||
|
checklistItems: Array.isArray(checklistItems)
|
||||||
|
? checklistItems.map(mapDispatchChecklistItem)
|
||||||
|
: [],
|
||||||
|
signoffs: Array.isArray(signoffs) ? signoffs.map(mapDispatchSignoff) : [],
|
||||||
|
comments: Array.isArray(comments) ? comments.map(mapDispatchComment) : [],
|
||||||
|
workOrders: Array.isArray(workOrders) ? workOrders.map(mapDispatchWorkOrderRef) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapDispatchToVendorPayload(
|
||||||
|
payload: DispatchToVendorPayload,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
WorkOrderIds: payload.workOrderIds,
|
||||||
|
VendorIds: payload.vendorIds,
|
||||||
|
NTEAmount: payload.nteAmount,
|
||||||
|
ScheduledDate: payload.scheduledDate,
|
||||||
|
EstimatedArrivalAt: payload.estimatedArrivalAt,
|
||||||
|
EtaManualOverride: payload.etaManualOverride ?? false,
|
||||||
|
Description: payload.description,
|
||||||
|
TaskListTemplateId: payload.taskListTemplateId,
|
||||||
|
CustomChecklistItems: payload.customChecklistItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapUpdateDispatchPayload(payload: UpdateDispatchPayload): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
Id: payload.id,
|
||||||
|
Status: payload.status,
|
||||||
|
NTEAmount: payload.nteAmount,
|
||||||
|
ScheduledDate: payload.scheduledDate,
|
||||||
|
EstimatedArrivalAt: payload.estimatedArrivalAt,
|
||||||
|
EtaManualOverride: payload.etaManualOverride ?? false,
|
||||||
|
CompletedDate: payload.completedDate,
|
||||||
|
Description: payload.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,9 @@
|
||||||
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
import { readDocStatusFromRecord } from "@/domain/work-orders/mappers/doc-status-mapper";
|
||||||
import {
|
import { parseSiteCodeFromLocationName } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
mapDocStatusToApi,
|
|
||||||
readDocStatusFromRecord,
|
|
||||||
} from "@/domain/work-orders/mappers/doc-status-mapper";
|
|
||||||
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
|
||||||
import type {
|
import type {
|
||||||
DispatchChecklistItem,
|
|
||||||
DispatchComment,
|
|
||||||
DispatchDetail,
|
|
||||||
DispatchSignoff,
|
|
||||||
DispatchToVendorPayload,
|
|
||||||
DispatchWorkOrderRef,
|
|
||||||
EmployeeOption,
|
EmployeeOption,
|
||||||
LocationOption,
|
LocationOption,
|
||||||
TaskListTemplate,
|
TaskListTemplate,
|
||||||
UpdateDispatchPayload,
|
|
||||||
WorkOrderAuditEntry,
|
WorkOrderAuditEntry,
|
||||||
WorkOrderComment,
|
WorkOrderComment,
|
||||||
WorkOrderDetail,
|
WorkOrderDetail,
|
||||||
|
|
@ -22,18 +11,12 @@ import type {
|
||||||
WorkOrderFormValues,
|
WorkOrderFormValues,
|
||||||
WorkOrderListItem,
|
WorkOrderListItem,
|
||||||
} from "@/domain/work-orders/types/work-order";
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
||||||
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common";
|
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common";
|
||||||
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order-common";
|
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order-common";
|
||||||
import type {
|
|
||||||
CompletionDocStatus,
|
|
||||||
WorkOrderTableRow,
|
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
|
||||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
|
||||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
|
||||||
import { formatApptRange } from "@/domain/work-orders/utils/wizard-date-utils";
|
|
||||||
import { toDateInputValue } from "@/lib/time-utils";
|
import { toDateInputValue } from "@/lib/time-utils";
|
||||||
|
|
||||||
export { mapDocStatusToApi };
|
export { mapDocStatusToApi } from "@/domain/work-orders/mappers/doc-status-mapper";
|
||||||
|
|
||||||
function asRecord(raw: unknown): Record<string, unknown> {
|
function asRecord(raw: unknown): Record<string, unknown> {
|
||||||
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||||
|
|
@ -43,7 +26,15 @@ function toWorkOrderStatus(value: string): WorkOrderStatus {
|
||||||
if (WORK_ORDER_STATUS_OPTIONS.includes(value as WorkOrderStatus)) {
|
if (WORK_ORDER_STATUS_OPTIONS.includes(value as WorkOrderStatus)) {
|
||||||
return value as WorkOrderStatus;
|
return value as WorkOrderStatus;
|
||||||
}
|
}
|
||||||
return "Open";
|
const fromEditEnum: Record<string, WorkOrderStatus> = {
|
||||||
|
Open: "Open",
|
||||||
|
InProgress: "In Progress",
|
||||||
|
OnHold: "On Hold",
|
||||||
|
Completed: "Done",
|
||||||
|
Cancelled: "Cancelled",
|
||||||
|
Canceled: "Cancelled",
|
||||||
|
};
|
||||||
|
return fromEditEnum[value] ?? "Open";
|
||||||
}
|
}
|
||||||
|
|
||||||
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||||
|
|
@ -83,6 +74,115 @@ function readNumber(record: Record<string, unknown>, ...keys: string[]): number
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readIdList(raw: unknown): number[] | undefined {
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids: number[] = [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
if (typeof entry === "number" && !Number.isNaN(entry)) {
|
||||||
|
ids.push(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof entry === "string" && entry !== "") {
|
||||||
|
const parsed = Number(entry);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
ids.push(parsed);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof entry === "object" && entry !== null) {
|
||||||
|
const id = readNumber(
|
||||||
|
asRecord(entry),
|
||||||
|
"id",
|
||||||
|
"Id",
|
||||||
|
"contactId",
|
||||||
|
"ContactId",
|
||||||
|
"categoryId",
|
||||||
|
"CategoryId",
|
||||||
|
);
|
||||||
|
if (id != null) {
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOptionalIdList(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
...keys: string[]
|
||||||
|
): number[] | undefined {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!(key in record)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const list = readIdList(record[key]);
|
||||||
|
if (list !== undefined) {
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORM_STATUS_TO_EDIT_ENUM: Record<string, string> = {
|
||||||
|
Open: "Open",
|
||||||
|
"In Progress": "InProgress",
|
||||||
|
InProgress: "InProgress",
|
||||||
|
"On Hold": "OnHold",
|
||||||
|
OnHold: "OnHold",
|
||||||
|
Done: "Completed",
|
||||||
|
Completed: "Completed",
|
||||||
|
Cancelled: "Cancelled",
|
||||||
|
Canceled: "Cancelled",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FORM_PRIORITY_TO_EDIT_ENUM: Record<string, string> = {
|
||||||
|
"Sev 1": "Critical",
|
||||||
|
"Sev 2": "High",
|
||||||
|
"Sev 3": "Medium",
|
||||||
|
"Sev 4": "Low",
|
||||||
|
"Sev 5": "Low",
|
||||||
|
Low: "Low",
|
||||||
|
Medium: "Medium",
|
||||||
|
High: "High",
|
||||||
|
Critical: "Critical",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mapFormStatusToEditEnum(status: string): string {
|
||||||
|
return FORM_STATUS_TO_EDIT_ENUM[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapFormPriorityToEditEnum(priority: string): string {
|
||||||
|
return FORM_PRIORITY_TO_EDIT_ENUM[priority] ?? priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendIfPresent(
|
||||||
|
formData: FormData,
|
||||||
|
key: string,
|
||||||
|
value: string | number | null | undefined,
|
||||||
|
): void {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const asString = String(value).trim();
|
||||||
|
if (asString === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formData.append(key, asString);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendIdList(formData: FormData, key: string, ids: number[] | undefined): void {
|
||||||
|
if (ids === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const id of ids) {
|
||||||
|
formData.append(key, String(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
|
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const value = record[key];
|
const value = record[key];
|
||||||
|
|
@ -114,120 +214,19 @@ export function mapWorkOrderListItems(rawItems: unknown[]): WorkOrderListItem[]
|
||||||
return rawItems.map(mapWorkOrderListItem);
|
return rawItems.map(mapWorkOrderListItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
const WO_TYPES_SET = new Set(["PM", "Reactive", "Emergency", "Add-On", "Overdue"]);
|
export { mapWorkOrderTableRow, mapWorkOrderTableRows } from "./work-order-table-row-mapper";
|
||||||
|
|
||||||
function readWoType(record: Record<string, unknown>): WOType | "" {
|
|
||||||
const raw = readString(record, "type", "Type", "source", "Source", "woType", "WoType");
|
|
||||||
if (WO_TYPES_SET.has(raw)) {
|
|
||||||
return raw as WOType;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function readDocStatus(record: Record<string, unknown>): CompletionDocStatus {
|
|
||||||
return readDocStatusFromRecord(record);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
|
||||||
const item = asRecord(raw);
|
|
||||||
const scheduledOn = toDateInputValue(
|
|
||||||
readString(item, "scheduledOn", "scheduledDate", "ScheduledDate", "ScheduledOn"),
|
|
||||||
);
|
|
||||||
const { dayGroup, dayLabel } = deriveDayFields(scheduledOn);
|
|
||||||
const woNumber = readString(
|
|
||||||
item,
|
|
||||||
"woNumber",
|
|
||||||
"internalWONumber",
|
|
||||||
"InternalWONumber",
|
|
||||||
"number",
|
|
||||||
"workOrderNumber",
|
|
||||||
"WorkerOrderNumber",
|
|
||||||
);
|
|
||||||
const site = readString(item, "site", "Site", "locationCode", "LocationCode");
|
|
||||||
const location = readString(item, "location", "Location", "locationName", "LocationName");
|
|
||||||
const dispatcherId = readString(
|
|
||||||
item,
|
|
||||||
"dispatcherId",
|
|
||||||
"DispatcherId",
|
|
||||||
"assigneeId",
|
|
||||||
"AssigneeId",
|
|
||||||
"employeeId",
|
|
||||||
"EmployeeId",
|
|
||||||
);
|
|
||||||
const dispatcherName = readString(
|
|
||||||
item,
|
|
||||||
"dispatcherName",
|
|
||||||
"DispatcherName",
|
|
||||||
"assignedTo",
|
|
||||||
"AssignedTo",
|
|
||||||
"employeeName",
|
|
||||||
"EmployeeName",
|
|
||||||
"assignee",
|
|
||||||
"Assignee",
|
|
||||||
);
|
|
||||||
const apptStart = readString(item, "apptStart", "ApptStart", "appointmentStart");
|
|
||||||
const apptEnd = readString(item, "apptEnd", "ApptEnd", "appointmentEnd");
|
|
||||||
const apptTime =
|
|
||||||
readString(item, "apptTime", "ApptTime") ||
|
|
||||||
(apptStart ? formatApptRange(apptStart, apptEnd) : "");
|
|
||||||
const apiStatus = readString(item, "status", "Status");
|
|
||||||
const locationIdRaw = item.locationId ?? item.LocationId;
|
|
||||||
|
|
||||||
const row: WorkOrderTableRow = {
|
|
||||||
id: readId(item),
|
|
||||||
woNumber,
|
|
||||||
site: site || location.slice(0, 8),
|
|
||||||
type: readWoType(item),
|
|
||||||
dispatcherId,
|
|
||||||
dispatcherName,
|
|
||||||
scheduledOn,
|
|
||||||
completedDate: toDateInputValue(readString(item, "completedDate", "CompletedDate")),
|
|
||||||
targetWeek: readString(item, "targetWeek", "TargetWeek"),
|
|
||||||
dayGroup,
|
|
||||||
dayLabel,
|
|
||||||
location,
|
|
||||||
locationId:
|
|
||||||
typeof locationIdRaw === "string" || typeof locationIdRaw === "number" ? locationIdRaw : "",
|
|
||||||
pm: readString(item, "pm", "PM", "problem", "Problem", "service", "Service"),
|
|
||||||
company: readString(item, "company", "Company", "vendorName", "VendorName"),
|
|
||||||
tech: readString(item, "tech", "Tech", "technician", "Technician"),
|
|
||||||
techPhone: readString(item, "techPhone", "TechPhone"),
|
|
||||||
apptStart,
|
|
||||||
apptEnd,
|
|
||||||
apptTime,
|
|
||||||
status: mapApiStatusToWizard(apiStatus),
|
|
||||||
docStatus: readDocStatus(item),
|
|
||||||
pocName: readString(item, "pocName", "PocName", "POCName"),
|
|
||||||
pocPhone: readString(item, "pocPhone", "PocPhone", "POCPhone"),
|
|
||||||
pocNotes: readString(item, "pocNotes", "PocNotes", "POCNotes"),
|
|
||||||
dueDate: toDateInputValue(readString(item, "dueDate", "DueDate")),
|
|
||||||
rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0,
|
|
||||||
carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0,
|
|
||||||
originalWeek: readString(item, "originalWeek", "OriginalWeek"),
|
|
||||||
originalDate: toDateInputValue(readString(item, "originalDate", "OriginalDate")),
|
|
||||||
isPastDue: readBool(item, "isPastDue", "IsPastDue"),
|
|
||||||
scheduleWeekOnly: readBool(item, "scheduleWeekOnly", "ScheduleWeekOnly"),
|
|
||||||
mediaCount: readNumber(item, "mediaCount", "MediaCount") ?? 0,
|
|
||||||
title: readString(item, "title", "Title", "workerOrderTitle", "WorkerOrderTitle"),
|
|
||||||
priority: readString(item, "priority", "Priority"),
|
|
||||||
};
|
|
||||||
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapWorkOrderTableRows(rawItems: unknown[]): WorkOrderTableRow[] {
|
|
||||||
return rawItems.map(mapWorkOrderTableRow);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
|
export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
|
const authorId = readString(item, "authorId", "AuthorId") || null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: readId(item),
|
id: readId(item),
|
||||||
createdDate: readString(item, "createdDate", "CreatedDate"),
|
createdDate: readString(item, "createdDate", "CreatedDate"),
|
||||||
commenttext: readString(item, "commenttext", "Commenttext", "text", "Text"),
|
commenttext: readString(item, "commenttext", "Commenttext", "text", "Text"),
|
||||||
commentType: readString(item, "commentType", "CommentType"),
|
commentType: readString(item, "commentType", "CommentType"),
|
||||||
userName: readString(item, "userName", "UserName"),
|
userName: readString(item, "userName", "UserName", "authorName", "AuthorName"),
|
||||||
|
authorId,
|
||||||
commenter: readString(item, "commenter", "Commenter") || null,
|
commenter: readString(item, "commenter", "Commenter") || null,
|
||||||
recordType: readString(item, "recordType", "RecordType") || null,
|
recordType: readString(item, "recordType", "RecordType") || null,
|
||||||
};
|
};
|
||||||
|
|
@ -235,6 +234,13 @@ export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
|
||||||
|
|
||||||
export function mapWorkOrderAuditEntry(raw: unknown): WorkOrderAuditEntry {
|
export function mapWorkOrderAuditEntry(raw: unknown): WorkOrderAuditEntry {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
|
const typeRaw = readString(item, "type", "Type").toLowerCase();
|
||||||
|
const type =
|
||||||
|
typeRaw === "system"
|
||||||
|
? ("system" as const)
|
||||||
|
: typeRaw === "manual"
|
||||||
|
? ("manual" as const)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: readId(item),
|
id: readId(item),
|
||||||
|
|
@ -242,8 +248,10 @@ export function mapWorkOrderAuditEntry(raw: unknown): WorkOrderAuditEntry {
|
||||||
oldValue: readString(item, "oldValue", "OldValue"),
|
oldValue: readString(item, "oldValue", "OldValue"),
|
||||||
newValue: readString(item, "newValue", "NewValue"),
|
newValue: readString(item, "newValue", "NewValue"),
|
||||||
action: readString(item, "action", "Action"),
|
action: readString(item, "action", "Action"),
|
||||||
createdAt: readString(item, "createdAt", "CreatedAt"),
|
createdAt: readString(item, "createdAt", "CreatedAt", "time", "Time"),
|
||||||
userName: readString(item, "userName", "UserName"),
|
userName: readString(item, "userName", "UserName"),
|
||||||
|
type,
|
||||||
|
dispatcherId: readString(item, "dispatcherId", "DispatcherId") || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -268,6 +276,14 @@ export function mapWorkOrderDispatchSummary(raw: unknown): WorkOrderDispatchSumm
|
||||||
export function mapWorkOrderDetail(raw: unknown): WorkOrderDetail {
|
export function mapWorkOrderDetail(raw: unknown): WorkOrderDetail {
|
||||||
const wo = asRecord(raw);
|
const wo = asRecord(raw);
|
||||||
const locationId = wo.locationId ?? wo.LocationId;
|
const locationId = wo.locationId ?? wo.LocationId;
|
||||||
|
const contactIds = readOptionalIdList(wo, "contactIds", "ContactIds", "contacts", "Contacts");
|
||||||
|
const categoryIds = readOptionalIdList(
|
||||||
|
wo,
|
||||||
|
"categoryIds",
|
||||||
|
"CategoryIds",
|
||||||
|
"categories",
|
||||||
|
"Categories",
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: readId(wo),
|
id: readId(wo),
|
||||||
|
|
@ -292,8 +308,11 @@ export function mapWorkOrderDetail(raw: unknown): WorkOrderDetail {
|
||||||
vendorNTE: wo.vendorNTE != null ? String(wo.vendorNTE) : "",
|
vendorNTE: wo.vendorNTE != null ? String(wo.vendorNTE) : "",
|
||||||
scheduledDate: toDateInputValue(readString(wo, "scheduledDate", "ScheduledDate")),
|
scheduledDate: toDateInputValue(readString(wo, "scheduledDate", "ScheduledDate")),
|
||||||
completedDate: toDateInputValue(readString(wo, "completedDate", "CompletedDate")),
|
completedDate: toDateInputValue(readString(wo, "completedDate", "CompletedDate")),
|
||||||
docStatus: readDocStatus(wo),
|
// DocStatus is a board field — persist via PATCH …/board, not EditWorkorder.
|
||||||
|
docStatus: readDocStatusFromRecord(wo),
|
||||||
source: readString(wo, "source", "Source"),
|
source: readString(wo, "source", "Source"),
|
||||||
|
...(contactIds !== undefined ? { contactIds } : {}),
|
||||||
|
...(categoryIds !== undefined ? { categoryIds } : {}),
|
||||||
comments: Array.isArray(wo.comments)
|
comments: Array.isArray(wo.comments)
|
||||||
? wo.comments.map(mapWorkOrderComment)
|
? wo.comments.map(mapWorkOrderComment)
|
||||||
: Array.isArray(wo.Comments)
|
: Array.isArray(wo.Comments)
|
||||||
|
|
@ -334,6 +353,8 @@ export function mapWorkOrderDetailToFormValues(detail: WorkOrderDetail): WorkOrd
|
||||||
subTrade: detail.subTrade,
|
subTrade: detail.subTrade,
|
||||||
vendorNTE: detail.vendorNTE,
|
vendorNTE: detail.vendorNTE,
|
||||||
source: detail.source,
|
source: detail.source,
|
||||||
|
...(detail.contactIds !== undefined ? { contactIds: detail.contactIds } : {}),
|
||||||
|
...(detail.categoryIds !== undefined ? { categoryIds: detail.categoryIds } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -350,7 +371,6 @@ export function mapWorkOrderFormToCreatePayload(
|
||||||
DueDate: values.dueDate || null,
|
DueDate: values.dueDate || null,
|
||||||
ScheduledDate: values.scheduledDate || null,
|
ScheduledDate: values.scheduledDate || null,
|
||||||
CompletedDate: values.completedDate || null,
|
CompletedDate: values.completedDate || null,
|
||||||
// DocStatus is a board field — persist via PATCH …/board, not EditWorkorder.
|
|
||||||
AssignTo: values.assignedTo || null,
|
AssignTo: values.assignedTo || null,
|
||||||
Description: values.description,
|
Description: values.description,
|
||||||
PO: values.po || null,
|
PO: values.po || null,
|
||||||
|
|
@ -363,40 +383,38 @@ export function mapWorkOrderFormToCreatePayload(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapWorkOrderFormToUpdatePayload(
|
export function mapWorkOrderFormToUpdateFormData(
|
||||||
id: string | number,
|
id: string | number,
|
||||||
values: WorkOrderFormValues,
|
values: WorkOrderFormValues,
|
||||||
): Record<string, unknown> {
|
): FormData {
|
||||||
return {
|
const formData = new FormData();
|
||||||
Id: id,
|
formData.append("Id", String(id));
|
||||||
WorkerOrderNumber: values.workOrderNumber,
|
|
||||||
WorkerOrderTitle: values.title,
|
appendIfPresent(formData, "WorkerOrderTitle", values.title);
|
||||||
Description: values.description,
|
appendIfPresent(formData, "Description", values.description);
|
||||||
PO: values.po || null,
|
appendIfPresent(formData, "Status", mapFormStatusToEditEnum(values.status));
|
||||||
TT: values.tt || null,
|
appendIfPresent(formData, "Priority", mapFormPriorityToEditEnum(values.priority));
|
||||||
AssignTo: values.assignedTo || null,
|
const locationId = values.locationId ? parseInt(values.locationId, 10) : NaN;
|
||||||
DueDate: values.dueDate || null,
|
appendIfPresent(formData, "LocationId", Number.isNaN(locationId) ? undefined : locationId);
|
||||||
Priority: values.priority || null,
|
appendIfPresent(formData, "AssignTo", values.assignedTo);
|
||||||
Status: values.status,
|
appendIfPresent(formData, "DueDate", values.dueDate);
|
||||||
LocationId: values.locationId ? parseInt(values.locationId, 10) : null,
|
appendIfPresent(formData, "CompletedDate", values.completedDate);
|
||||||
Problem: values.problem || null,
|
appendIdList(formData, "ContactIds", values.contactIds);
|
||||||
Trade: values.trade || null,
|
appendIdList(formData, "CategoryIds", values.categoryIds);
|
||||||
SubTrade: values.subTrade || null,
|
|
||||||
VendorNTE: values.vendorNTE ? parseFloat(values.vendorNTE) : null,
|
return formData;
|
||||||
ScheduledDate: values.scheduledDate || null,
|
|
||||||
CompletedDate: values.completedDate || null,
|
|
||||||
// DocStatus is a board field — persist via PATCH …/board, not EditWorkorder.
|
|
||||||
CreatedDate: values.createdDate || null,
|
|
||||||
Source: values.source || null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapLocationOption(raw: unknown): LocationOption {
|
export function mapLocationOption(raw: unknown): LocationOption {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
|
const name = readString(item, "name", "Name", "locationName", "LocationName");
|
||||||
|
const siteCode =
|
||||||
|
readString(item, "siteCode", "SiteCode", "code", "Code") || parseSiteCodeFromLocationName(name);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: readId(item),
|
id: readId(item),
|
||||||
name: readString(item, "name", "Name", "locationName", "LocationName"),
|
name,
|
||||||
|
...(siteCode ? { siteCode } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -436,114 +454,15 @@ export function mapTaskListTemplates(rawItems: unknown[]): TaskListTemplate[] {
|
||||||
return rawItems.map(mapTaskListTemplate);
|
return rawItems.map(mapTaskListTemplate);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapDispatchChecklistItem(raw: unknown): DispatchChecklistItem {
|
export {
|
||||||
const item = asRecord(raw);
|
mapDispatchChecklistItem,
|
||||||
|
mapDispatchComment,
|
||||||
return {
|
mapDispatchDetail,
|
||||||
id: readId(item),
|
mapDispatchSignoff,
|
||||||
itemText: readString(item, "itemText", "ItemText"),
|
mapDispatchToVendorPayload,
|
||||||
isCompleted: readBool(item, "isCompleted", "IsCompleted"),
|
mapDispatchWorkOrderRef,
|
||||||
completedBy: readString(item, "completedBy", "CompletedBy") || null,
|
mapUpdateDispatchPayload,
|
||||||
completedAt: readString(item, "completedAt", "CompletedAt") || null,
|
} from "./work-order-dispatch-mapper";
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapDispatchSignoff(raw: unknown): DispatchSignoff {
|
|
||||||
const item = asRecord(raw);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: readId(item),
|
|
||||||
signoffType: readString(item, "signoffType", "SignoffType"),
|
|
||||||
name: readString(item, "name", "Name"),
|
|
||||||
signature: readString(item, "signature", "Signature"),
|
|
||||||
signatureMethod: readString(item, "signatureMethod", "SignatureMethod"),
|
|
||||||
signedAt: readString(item, "signedAt", "SignedAt"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapDispatchComment(raw: unknown): DispatchComment {
|
|
||||||
const item = asRecord(raw);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: readId(item),
|
|
||||||
createdDate: readString(item, "createdDate", "CreatedDate"),
|
|
||||||
commenttext: readString(item, "commenttext", "Commenttext", "text", "Text"),
|
|
||||||
userName: readString(item, "userName", "UserName"),
|
|
||||||
commenter: readString(item, "commenter", "Commenter") || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapDispatchWorkOrderRef(raw: unknown): DispatchWorkOrderRef {
|
|
||||||
const item = asRecord(raw);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: readId(item),
|
|
||||||
woNumber: readString(item, "woNumber", "WoNumber", "WONumber", "number", "Number"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapDispatchDetail(raw: unknown): DispatchDetail {
|
|
||||||
const item = asRecord(raw);
|
|
||||||
const checklistItems = item.checklistItems ?? item.ChecklistItems;
|
|
||||||
const signoffs = item.signoffs ?? item.Signoffs;
|
|
||||||
const comments = item.comments ?? item.Comments;
|
|
||||||
const workOrders = item.workOrders ?? item.WorkOrders;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: readId(item),
|
|
||||||
dispatchNumber: readString(item, "dispatchNumber", "DispatchNumber"),
|
|
||||||
poNumber: readString(item, "poNumber", "PoNumber", "PONumber"),
|
|
||||||
vendorName: readString(item, "vendorName", "VendorName"),
|
|
||||||
vendorEmail: readString(item, "vendorEmail", "VendorEmail"),
|
|
||||||
woNumber: readString(item, "woNumber", "WoNumber"),
|
|
||||||
status: readString(item, "status", "Status"),
|
|
||||||
nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
|
|
||||||
scheduledDate: readString(item, "scheduledDate", "ScheduledDate"),
|
|
||||||
estimatedArrivalAt: readString(item, "estimatedArrivalAt", "EstimatedArrivalAt"),
|
|
||||||
etaManualOverride: readBool(item, "etaManualOverride", "EtaManualOverride"),
|
|
||||||
completedDate: readString(item, "completedDate", "CompletedDate"),
|
|
||||||
description: readString(item, "description", "Description"),
|
|
||||||
dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),
|
|
||||||
acknowledgedAt: readString(item, "acknowledgedAt", "AcknowledgedAt"),
|
|
||||||
verifiedAt: readString(item, "verifiedAt", "VerifiedAt"),
|
|
||||||
verifiedBy: readString(item, "verifiedBy", "VerifiedBy"),
|
|
||||||
checklistItems: Array.isArray(checklistItems)
|
|
||||||
? checklistItems.map(mapDispatchChecklistItem)
|
|
||||||
: [],
|
|
||||||
signoffs: Array.isArray(signoffs) ? signoffs.map(mapDispatchSignoff) : [],
|
|
||||||
comments: Array.isArray(comments) ? comments.map(mapDispatchComment) : [],
|
|
||||||
workOrders: Array.isArray(workOrders) ? workOrders.map(mapDispatchWorkOrderRef) : [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapDispatchToVendorPayload(
|
|
||||||
payload: DispatchToVendorPayload,
|
|
||||||
): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
WorkOrderIds: payload.workOrderIds,
|
|
||||||
VendorIds: payload.vendorIds,
|
|
||||||
NTEAmount: payload.nteAmount,
|
|
||||||
ScheduledDate: payload.scheduledDate,
|
|
||||||
EstimatedArrivalAt: payload.estimatedArrivalAt,
|
|
||||||
EtaManualOverride: payload.etaManualOverride ?? false,
|
|
||||||
Description: payload.description,
|
|
||||||
TaskListTemplateId: payload.taskListTemplateId,
|
|
||||||
CustomChecklistItems: payload.customChecklistItems,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapUpdateDispatchPayload(payload: UpdateDispatchPayload): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
Id: payload.id,
|
|
||||||
Status: payload.status,
|
|
||||||
NTEAmount: payload.nteAmount,
|
|
||||||
ScheduledDate: payload.scheduledDate,
|
|
||||||
EstimatedArrivalAt: payload.estimatedArrivalAt,
|
|
||||||
EtaManualOverride: payload.etaManualOverride ?? false,
|
|
||||||
CompletedDate: payload.completedDate,
|
|
||||||
Description: payload.description,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapUpliftRequest(raw: unknown): UpliftRequest {
|
export function mapUpliftRequest(raw: unknown): UpliftRequest {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
|
|
|
||||||
282
src/domain/work-orders/mappers/work-order-table-row-mapper.ts
Normal file
282
src/domain/work-orders/mappers/work-order-table-row-mapper.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
||||||
|
import {
|
||||||
|
DOC_STATUS_API,
|
||||||
|
LIFECYCLE_STATUS_API_TO_FE,
|
||||||
|
WORK_ORDER_TYPE_API_TO_FE,
|
||||||
|
} from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import type {
|
||||||
|
CompletionDocStatus,
|
||||||
|
WorkOrderTableRow,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
|
import { formatApptRange } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
import { toDateInputValue } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
function asRecord(raw: unknown): Record<string, unknown> {
|
||||||
|
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readId(record: Record<string, unknown>): string | number {
|
||||||
|
const value = record.id ?? record.Id;
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(record: Record<string, unknown>, ...keys: string[]): number | null {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value !== "") {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStringArray(record: Record<string, unknown>, ...keys: string[]): string[] {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.filter((item): item is string => typeof item === "string")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(value);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed
|
||||||
|
.filter((item): item is string => typeof item === "string")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore invalid JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRowVersion(record: Record<string, unknown>, ...keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "string" && value.length > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const WO_TYPES_SET = new Set(["PM", "Reactive", "Emergency", "Add-On", "Overdue"]);
|
||||||
|
|
||||||
|
function readWoType(record: Record<string, unknown>): WOType | "" {
|
||||||
|
const numeric =
|
||||||
|
readNumber(record, "workOrderType", "WorkOrderType") ?? readNumber(record, "type", "Type");
|
||||||
|
if (numeric != null && WORK_ORDER_TYPE_API_TO_FE[numeric]) {
|
||||||
|
return WORK_ORDER_TYPE_API_TO_FE[numeric] as WOType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = readString(
|
||||||
|
record,
|
||||||
|
"workOrderType",
|
||||||
|
"WorkOrderType",
|
||||||
|
"type",
|
||||||
|
"Type",
|
||||||
|
"woType",
|
||||||
|
"WoType",
|
||||||
|
"source",
|
||||||
|
"Source",
|
||||||
|
);
|
||||||
|
if (WO_TYPES_SET.has(raw)) {
|
||||||
|
return raw as WOType;
|
||||||
|
}
|
||||||
|
if (raw === "AddOn") {
|
||||||
|
return "Add-On";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDocStatus(record: Record<string, unknown>): CompletionDocStatus {
|
||||||
|
const numeric = readNumber(record, "docStatus", "DocStatus");
|
||||||
|
if (numeric === DOC_STATUS_API.Yes) return "Yes";
|
||||||
|
if (numeric === DOC_STATUS_API.No) return "No";
|
||||||
|
if (numeric === DOC_STATUS_API.NotNeeded) return "NN";
|
||||||
|
|
||||||
|
const raw = readString(record, "docStatus", "DocStatus", "completionDoc", "CompletionDoc");
|
||||||
|
if (raw === "Yes" || raw === "No" || raw === "NN") {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
if (raw === "NotNeeded") {
|
||||||
|
return "NN";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBoardStatus(record: Record<string, unknown>): ReturnType<typeof mapApiStatusToWizard> {
|
||||||
|
const label = readString(record, "lifecycleStatusLabel", "LifecycleStatusLabel");
|
||||||
|
if (label) {
|
||||||
|
return mapApiStatusToWizard(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lifecycle = readNumber(record, "lifecycleStatus", "LifecycleStatus");
|
||||||
|
if (lifecycle != null && LIFECYCLE_STATUS_API_TO_FE[lifecycle]) {
|
||||||
|
return mapApiStatusToWizard(LIFECYCLE_STATUS_API_TO_FE[lifecycle]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacy = readString(record, "legacyStatus", "LegacyStatus", "status", "Status");
|
||||||
|
return mapApiStatusToWizard(legacy);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFlagColor(record: Record<string, unknown>): string | null {
|
||||||
|
for (const key of ["flagColor", "FlagColor"]) {
|
||||||
|
const value = record[key];
|
||||||
|
if (value == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
const scheduledOn = toDateInputValue(
|
||||||
|
readString(item, "scheduledOn", "scheduledDate", "ScheduledDate", "ScheduledOn"),
|
||||||
|
);
|
||||||
|
// FE day groups use MM/DD keys (week skeleton); ignore BE weekday labels like "monday".
|
||||||
|
const { dayGroup, dayLabel } = deriveDayFields(scheduledOn);
|
||||||
|
const woNumber = readString(
|
||||||
|
item,
|
||||||
|
"woNumber",
|
||||||
|
"internalWONumber",
|
||||||
|
"InternalWONumber",
|
||||||
|
"number",
|
||||||
|
"workOrderNumber",
|
||||||
|
"WorkerOrderNumber",
|
||||||
|
);
|
||||||
|
const site = readString(
|
||||||
|
item,
|
||||||
|
"siteCode",
|
||||||
|
"SiteCode",
|
||||||
|
"site",
|
||||||
|
"Site",
|
||||||
|
"locationCode",
|
||||||
|
"LocationCode",
|
||||||
|
);
|
||||||
|
const location = readString(item, "location", "Location", "locationName", "LocationName");
|
||||||
|
const dispatcherId = readString(
|
||||||
|
item,
|
||||||
|
"dispatcherId",
|
||||||
|
"DispatcherId",
|
||||||
|
"assigneeId",
|
||||||
|
"AssigneeId",
|
||||||
|
"employeeId",
|
||||||
|
"EmployeeId",
|
||||||
|
);
|
||||||
|
const dispatcherName = readString(
|
||||||
|
item,
|
||||||
|
"dispatcherName",
|
||||||
|
"DispatcherName",
|
||||||
|
"assignedTo",
|
||||||
|
"AssignedTo",
|
||||||
|
"employeeName",
|
||||||
|
"EmployeeName",
|
||||||
|
"assignee",
|
||||||
|
"Assignee",
|
||||||
|
);
|
||||||
|
const apptStart = readString(item, "apptStart", "ApptStart", "appointmentStart");
|
||||||
|
const apptEnd = readString(item, "apptEnd", "ApptEnd", "appointmentEnd");
|
||||||
|
const apptTime =
|
||||||
|
readString(item, "apptTime", "ApptTime") ||
|
||||||
|
(apptStart ? formatApptRange(apptStart, apptEnd) : "");
|
||||||
|
const locationIdRaw = item.locationId ?? item.LocationId;
|
||||||
|
const vendorIdNum = readNumber(item, "vendorId", "VendorId");
|
||||||
|
const primaryDispatchId = readNumber(item, "primaryDispatchId", "PrimaryDispatchId");
|
||||||
|
|
||||||
|
const row: WorkOrderTableRow = {
|
||||||
|
id: readId(item),
|
||||||
|
woNumber,
|
||||||
|
site: site || location.slice(0, 8),
|
||||||
|
type: readWoType(item),
|
||||||
|
dispatcherId,
|
||||||
|
dispatcherName,
|
||||||
|
scheduledOn,
|
||||||
|
targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")),
|
||||||
|
dayGroup,
|
||||||
|
dayLabel,
|
||||||
|
location,
|
||||||
|
locationId:
|
||||||
|
typeof locationIdRaw === "string" || typeof locationIdRaw === "number" ? locationIdRaw : "",
|
||||||
|
pm: readString(item, "pm", "PM", "problem", "Problem", "service", "Service", "trade", "Trade"),
|
||||||
|
serviceNotes: readString(item, "serviceNotes", "ServiceNotes", "pmNote", "PmNote"),
|
||||||
|
extraServices: readStringArray(item, "extraServices", "ExtraServices"),
|
||||||
|
vendorId: vendorIdNum != null ? String(vendorIdNum) : "",
|
||||||
|
company: readString(item, "company", "Company", "vendorName", "VendorName"),
|
||||||
|
tech: readString(item, "techName", "TechName", "tech", "Tech", "technician", "Technician"),
|
||||||
|
techPhone: readString(item, "techPhone", "TechPhone"),
|
||||||
|
apptStart,
|
||||||
|
apptEnd,
|
||||||
|
apptTime,
|
||||||
|
status: readBoardStatus(item),
|
||||||
|
docStatus: readDocStatus(item),
|
||||||
|
pocName: readString(item, "pocName", "PocName", "POCName"),
|
||||||
|
pocPhone: readString(item, "pocPhone", "PocPhone", "POCPhone"),
|
||||||
|
pocNotes: readString(item, "pocNotes", "PocNotes", "POCNotes"),
|
||||||
|
dueDate: toDateInputValue(readString(item, "dueDate", "DueDate")),
|
||||||
|
rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0,
|
||||||
|
carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0,
|
||||||
|
originalWeek: readString(item, "originalWeek", "OriginalWeek"),
|
||||||
|
originalDate: toDateInputValue(readString(item, "originalDate", "OriginalDate")),
|
||||||
|
isPastDue: readBool(item, "isPastDue", "IsPastDue"),
|
||||||
|
scheduleWeekOnly: readBool(item, "scheduleWeekOnly", "ScheduleWeekOnly"),
|
||||||
|
mediaCount: readNumber(item, "mediaCount", "MediaCount") ?? 0,
|
||||||
|
title: readString(item, "title", "Title", "workerOrderTitle", "WorkerOrderTitle"),
|
||||||
|
priority: readString(item, "priority", "Priority"),
|
||||||
|
primaryDispatchId,
|
||||||
|
rowVersion: readRowVersion(item, "rowVersion", "RowVersion"),
|
||||||
|
dispatchRowVersion: readRowVersion(item, "dispatchRowVersion", "DispatchRowVersion"),
|
||||||
|
flagColor: readFlagColor(item),
|
||||||
|
};
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapWorkOrderTableRows(rawItems: unknown[]): WorkOrderTableRow[] {
|
||||||
|
return rawItems.map(mapWorkOrderTableRow);
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,7 @@ export function mapVendorDropdownItem(raw: unknown): VendorDropdownItem {
|
||||||
return {
|
return {
|
||||||
id: readId(item),
|
id: readId(item),
|
||||||
companyName: readString(item, "companyName", "CompanyName"),
|
companyName: readString(item, "companyName", "CompanyName"),
|
||||||
|
contactName: readString(item, "contactName", "ContactName"),
|
||||||
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||||
address: readString(item, "address", "Address"),
|
address: readString(item, "address", "Address"),
|
||||||
distanceMiles: readNumber(item, "distanceMiles", "DistanceMiles"),
|
distanceMiles: readNumber(item, "distanceMiles", "DistanceMiles"),
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
|
||||||
|
|
||||||
export type MockDispatcherUser = WorkOrderUser & { color: string };
|
|
||||||
|
|
||||||
/** UI-only dispatchers until the list filter is wired to the API. */
|
|
||||||
export const MOCK_DISPATCHER_USERS: MockDispatcherUser[] = [
|
|
||||||
{ id: "mock-dispatcher-sarah", name: "Sarah Chen", color: "#F59E0B" },
|
|
||||||
{ id: "mock-dispatcher-alyssa", name: "Alyssa Reeves", color: "#0D9488" },
|
|
||||||
{ id: "mock-dispatcher-derek", name: "Derek Walsh", color: "#6366F1" },
|
|
||||||
{ id: "mock-dispatcher-morgan", name: "Morgan Blake", color: "#F43F5E" },
|
|
||||||
{ id: "mock-dispatcher-james", name: "James Torres", color: "#7C3AED" },
|
|
||||||
{ id: "mock-dispatcher-rachel", name: "Rachel Kim", color: "#0EA5E9" },
|
|
||||||
{ id: "mock-dispatcher-tiffany", name: "Tiffany Moore", color: "#16A34A" },
|
|
||||||
{ id: "mock-dispatcher-brandon", name: "Brandon Lee", color: "#EA580C" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const MOCK_DISPATCHER_COLOR_BY_ID = Object.fromEntries(
|
|
||||||
MOCK_DISPATCHER_USERS.map((dispatcher) => [dispatcher.id, dispatcher.color]),
|
|
||||||
) as Record<string, string>;
|
|
||||||
|
|
||||||
const FALLBACK_AVATAR_COLORS = [
|
|
||||||
"#F59E0B",
|
|
||||||
"#0D9488",
|
|
||||||
"#6366F1",
|
|
||||||
"#F43F5E",
|
|
||||||
"#7C3AED",
|
|
||||||
"#0EA5E9",
|
|
||||||
"#16A34A",
|
|
||||||
"#EA580C",
|
|
||||||
];
|
|
||||||
|
|
||||||
function paletteColorFromKey(key: string): string {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < key.length; i += 1) {
|
|
||||||
hash = key.charCodeAt(i) + ((hash << 5) - hash);
|
|
||||||
}
|
|
||||||
return FALLBACK_AVATAR_COLORS[Math.abs(hash) % FALLBACK_AVATAR_COLORS.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDispatcherAvatarColor(user: WorkOrderUser): string {
|
|
||||||
return (
|
|
||||||
MOCK_DISPATCHER_COLOR_BY_ID[String(user.id)] ??
|
|
||||||
paletteColorFromKey(String(user.id || user.name))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass";
|
|
||||||
|
|
||||||
/** Use seeded WO table rows instead of GetWorkOrderList (dev / auth bypass). */
|
|
||||||
export function isWoTableMockEnabled(): boolean {
|
|
||||||
return isAuthBypassEnabled() || import.meta.env.VITE_WO_TABLE_MOCK === "true";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldUseMockWorkOrderDetail(id: string | number): boolean {
|
|
||||||
return isWoTableMockEnabled() && String(id).startsWith("mock-wo-");
|
|
||||||
}
|
|
||||||
|
|
@ -1,523 +0,0 @@
|
||||||
import type {
|
|
||||||
LocationOption,
|
|
||||||
VendorDropdownItem,
|
|
||||||
WorkOrderDetail,
|
|
||||||
WorkOrderUser,
|
|
||||||
} from "@/domain/work-orders/types/work-order";
|
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
|
||||||
import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list";
|
|
||||||
import { MOCK_DISPATCHER_USERS } from "@/domain/work-orders/mocks/dispatcher-filter-mock-data";
|
|
||||||
import {
|
|
||||||
MOCK_WO_WIZARD_LOCATIONS,
|
|
||||||
MOCK_WO_WIZARD_SERVICES,
|
|
||||||
MOCK_WO_WIZARD_VENDORS,
|
|
||||||
} from "@/domain/work-orders/mocks/wo-wizard-mock-data";
|
|
||||||
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
|
||||||
import { deriveDayFields, isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
|
|
||||||
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
|
|
||||||
import { addDaysIso, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
|
||||||
|
|
||||||
export const MOCK_WO_TABLE_USERS: WorkOrderUser[] = MOCK_DISPATCHER_USERS.map(({ id, name }) => ({
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function sched(iso: string): Pick<WorkOrderTableRow, "scheduledOn" | "dayGroup" | "dayLabel"> {
|
|
||||||
const { dayGroup, dayLabel } = deriveDayFields(iso);
|
|
||||||
return { scheduledOn: iso, dayGroup, dayLabel };
|
|
||||||
}
|
|
||||||
|
|
||||||
type RowSeed = {
|
|
||||||
id: string | number;
|
|
||||||
woNumber: string;
|
|
||||||
site: string;
|
|
||||||
location: string;
|
|
||||||
locationId: string | number;
|
|
||||||
type: WorkOrderTableRow["type"];
|
|
||||||
dispatcherId: string;
|
|
||||||
dispatcherName: string;
|
|
||||||
scheduledOn?: string;
|
|
||||||
targetWeek?: string;
|
|
||||||
dueDate: string;
|
|
||||||
pm: string;
|
|
||||||
company: string;
|
|
||||||
tech: string;
|
|
||||||
techPhone: string;
|
|
||||||
apptStart: string;
|
|
||||||
apptEnd: string;
|
|
||||||
apptTime: string;
|
|
||||||
status: WorkOrderTableRow["status"];
|
|
||||||
docStatus: WorkOrderTableRow["docStatus"];
|
|
||||||
pocName: string;
|
|
||||||
pocPhone: string;
|
|
||||||
pocNotes: string;
|
|
||||||
rescheduleCount: number;
|
|
||||||
carriedOver: number;
|
|
||||||
originalWeek: string;
|
|
||||||
originalDate: string;
|
|
||||||
scheduleWeekOnly: boolean;
|
|
||||||
mediaCount: number;
|
|
||||||
title?: string;
|
|
||||||
priority?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildRow(seed: RowSeed): WorkOrderTableRow {
|
|
||||||
const scheduledOn = seed.scheduledOn ?? "";
|
|
||||||
const dayFields = scheduledOn ? sched(scheduledOn) : deriveDayFields("");
|
|
||||||
return enrichTableRow({
|
|
||||||
title: seed.title ?? `${seed.type || "Work Order"} — ${seed.site}`,
|
|
||||||
priority: seed.priority ?? "Sev 3",
|
|
||||||
...seed,
|
|
||||||
...dayFields,
|
|
||||||
scheduledOn,
|
|
||||||
completedDate: seed.status === "Completed" ? todayIso() : "",
|
|
||||||
targetWeek: seed.targetWeek ?? "",
|
|
||||||
isPastDue: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMockRows(): WorkOrderTableRow[] {
|
|
||||||
const mon = currentWeekMonday();
|
|
||||||
const tue = addDaysIso(mon, 1);
|
|
||||||
const wed = addDaysIso(mon, 2);
|
|
||||||
const thu = addDaysIso(mon, 3);
|
|
||||||
const fri = addDaysIso(mon, 4);
|
|
||||||
const yesterday = addDaysIso(todayIso(), -1);
|
|
||||||
|
|
||||||
const sarah = MOCK_DISPATCHER_USERS[0];
|
|
||||||
const alyssa = MOCK_DISPATCHER_USERS[1];
|
|
||||||
const derek = MOCK_DISPATCHER_USERS[2];
|
|
||||||
const morgan = MOCK_DISPATCHER_USERS[3];
|
|
||||||
|
|
||||||
return [
|
|
||||||
buildRow({
|
|
||||||
id: "mock-wo-1",
|
|
||||||
woNumber: "20260623001",
|
|
||||||
site: "CHI-1042",
|
|
||||||
location: "Chicago Loop",
|
|
||||||
locationId: "mock-loc-1",
|
|
||||||
type: "PM",
|
|
||||||
dispatcherId: String(sarah.id),
|
|
||||||
dispatcherName: sarah.name,
|
|
||||||
...sched(mon),
|
|
||||||
dueDate: fri,
|
|
||||||
pm: "HVAC — Quarterly PM",
|
|
||||||
company: "Midwest HVAC Co.",
|
|
||||||
tech: "Mike Torres",
|
|
||||||
techPhone: "(312) 555-0198",
|
|
||||||
apptStart: "09:00",
|
|
||||||
apptEnd: "11:00",
|
|
||||||
apptTime: "09:00 – 11:00",
|
|
||||||
status: "Scheduled",
|
|
||||||
docStatus: "No",
|
|
||||||
pocName: "Maria Gonzalez",
|
|
||||||
pocPhone: "(312) 555-0142",
|
|
||||||
pocNotes: "Loading dock B",
|
|
||||||
rescheduleCount: 0,
|
|
||||||
carriedOver: 0,
|
|
||||||
originalWeek: "",
|
|
||||||
originalDate: "",
|
|
||||||
scheduleWeekOnly: false,
|
|
||||||
mediaCount: 0,
|
|
||||||
}),
|
|
||||||
buildRow({
|
|
||||||
id: "mock-wo-2",
|
|
||||||
woNumber: "20260623002",
|
|
||||||
site: "IND-2208",
|
|
||||||
location: "Indianapolis North",
|
|
||||||
locationId: "mock-loc-2",
|
|
||||||
type: "Emergency",
|
|
||||||
dispatcherId: String(alyssa.id),
|
|
||||||
dispatcherName: alyssa.name,
|
|
||||||
...sched(tue),
|
|
||||||
dueDate: tue,
|
|
||||||
pm: "Plumbing — Leak repair",
|
|
||||||
company: "Great Lakes Plumbing",
|
|
||||||
tech: "Jen Walsh",
|
|
||||||
techPhone: "(317) 555-4412",
|
|
||||||
apptStart: "08:00",
|
|
||||||
apptEnd: "",
|
|
||||||
apptTime: "08:00",
|
|
||||||
status: "On Site",
|
|
||||||
docStatus: "No",
|
|
||||||
pocName: "Chris Bennett",
|
|
||||||
pocPhone: "(317) 555-2208",
|
|
||||||
pocNotes: "",
|
|
||||||
rescheduleCount: 1,
|
|
||||||
carriedOver: 0,
|
|
||||||
originalWeek: "",
|
|
||||||
originalDate: "",
|
|
||||||
scheduleWeekOnly: false,
|
|
||||||
mediaCount: 3,
|
|
||||||
}),
|
|
||||||
buildRow({
|
|
||||||
id: "mock-wo-3",
|
|
||||||
woNumber: "20260623003",
|
|
||||||
site: "MIL-3315",
|
|
||||||
location: "Milwaukee West",
|
|
||||||
locationId: "mock-loc-3",
|
|
||||||
type: "Reactive",
|
|
||||||
dispatcherId: String(derek.id),
|
|
||||||
dispatcherName: derek.name,
|
|
||||||
...sched(wed),
|
|
||||||
dueDate: thu,
|
|
||||||
pm: "Electrical — Panel inspection",
|
|
||||||
company: "SparkLine Electric",
|
|
||||||
tech: "Pat Nguyen",
|
|
||||||
techPhone: "",
|
|
||||||
apptStart: "13:00",
|
|
||||||
apptEnd: "15:00",
|
|
||||||
apptTime: "13:00 – 15:00",
|
|
||||||
status: "In Progress",
|
|
||||||
docStatus: "Yes",
|
|
||||||
pocName: "Taylor Brooks",
|
|
||||||
pocPhone: "(414) 555-3315",
|
|
||||||
pocNotes: "Badge required at front desk",
|
|
||||||
rescheduleCount: 0,
|
|
||||||
carriedOver: 0,
|
|
||||||
originalWeek: "",
|
|
||||||
originalDate: "",
|
|
||||||
scheduleWeekOnly: false,
|
|
||||||
mediaCount: 0,
|
|
||||||
}),
|
|
||||||
buildRow({
|
|
||||||
id: "mock-wo-4",
|
|
||||||
woNumber: "20260623004",
|
|
||||||
site: "CHI-1042",
|
|
||||||
location: "Chicago Loop",
|
|
||||||
locationId: "mock-loc-1",
|
|
||||||
type: "Add-On",
|
|
||||||
dispatcherId: String(morgan.id),
|
|
||||||
dispatcherName: morgan.name,
|
|
||||||
...sched(thu),
|
|
||||||
dueDate: fri,
|
|
||||||
pm: "Roof — Membrane patch",
|
|
||||||
company: "Midwest HVAC Co.",
|
|
||||||
tech: "Mike Torres",
|
|
||||||
techPhone: "(312) 555-0198",
|
|
||||||
apptStart: "10:30",
|
|
||||||
apptEnd: "12:00",
|
|
||||||
apptTime: "10:30 – 12:00",
|
|
||||||
status: "Pending",
|
|
||||||
docStatus: "NN",
|
|
||||||
pocName: "Maria Gonzalez",
|
|
||||||
pocPhone: "(312) 555-0142",
|
|
||||||
pocNotes: "",
|
|
||||||
rescheduleCount: 0,
|
|
||||||
carriedOver: 0,
|
|
||||||
originalWeek: "",
|
|
||||||
originalDate: "",
|
|
||||||
scheduleWeekOnly: false,
|
|
||||||
mediaCount: 0,
|
|
||||||
}),
|
|
||||||
buildRow({
|
|
||||||
id: "mock-wo-5",
|
|
||||||
woNumber: "20260623005",
|
|
||||||
site: "IND-2208",
|
|
||||||
location: "Indianapolis North",
|
|
||||||
locationId: "mock-loc-2",
|
|
||||||
type: "PM",
|
|
||||||
dispatcherId: "",
|
|
||||||
dispatcherName: "",
|
|
||||||
...sched(fri),
|
|
||||||
dueDate: fri,
|
|
||||||
pm: "HVAC — Quarterly PM",
|
|
||||||
company: "",
|
|
||||||
tech: "",
|
|
||||||
techPhone: "",
|
|
||||||
apptStart: "",
|
|
||||||
apptEnd: "",
|
|
||||||
apptTime: "",
|
|
||||||
status: "Incomplete",
|
|
||||||
docStatus: "No",
|
|
||||||
pocName: "",
|
|
||||||
pocPhone: "",
|
|
||||||
pocNotes: "",
|
|
||||||
rescheduleCount: 0,
|
|
||||||
carriedOver: 0,
|
|
||||||
originalWeek: "",
|
|
||||||
originalDate: "",
|
|
||||||
scheduleWeekOnly: false,
|
|
||||||
mediaCount: 0,
|
|
||||||
}),
|
|
||||||
buildRow({
|
|
||||||
id: "mock-wo-6",
|
|
||||||
woNumber: "20260623006",
|
|
||||||
site: "MIL-3315",
|
|
||||||
location: "Milwaukee West",
|
|
||||||
locationId: "mock-loc-3",
|
|
||||||
type: "Overdue",
|
|
||||||
dispatcherId: String(sarah.id),
|
|
||||||
dispatcherName: sarah.name,
|
|
||||||
...sched(mon),
|
|
||||||
dueDate: yesterday,
|
|
||||||
pm: "Plumbing — Leak repair",
|
|
||||||
company: "Great Lakes Plumbing",
|
|
||||||
tech: "Jen Walsh",
|
|
||||||
techPhone: "(317) 555-4412",
|
|
||||||
apptStart: "07:00",
|
|
||||||
apptEnd: "09:00",
|
|
||||||
apptTime: "07:00 – 09:00",
|
|
||||||
status: "Scheduled",
|
|
||||||
docStatus: "No",
|
|
||||||
pocName: "Taylor Brooks",
|
|
||||||
pocPhone: "(414) 555-3315",
|
|
||||||
pocNotes: "",
|
|
||||||
rescheduleCount: 2,
|
|
||||||
carriedOver: 1,
|
|
||||||
originalWeek: "",
|
|
||||||
originalDate: yesterday,
|
|
||||||
scheduleWeekOnly: false,
|
|
||||||
mediaCount: 0,
|
|
||||||
}),
|
|
||||||
buildRow({
|
|
||||||
id: "mock-wo-7",
|
|
||||||
woNumber: "20260623007",
|
|
||||||
site: "CHI-1042",
|
|
||||||
location: "Chicago Loop",
|
|
||||||
locationId: "mock-loc-1",
|
|
||||||
type: "Reactive",
|
|
||||||
dispatcherId: String(alyssa.id),
|
|
||||||
dispatcherName: alyssa.name,
|
|
||||||
scheduledOn: "",
|
|
||||||
targetWeek: mon,
|
|
||||||
dueDate: wed,
|
|
||||||
pm: "Electrical — Panel inspection",
|
|
||||||
company: "SparkLine Electric",
|
|
||||||
tech: "Pat Nguyen",
|
|
||||||
techPhone: "",
|
|
||||||
apptStart: "",
|
|
||||||
apptEnd: "",
|
|
||||||
apptTime: "",
|
|
||||||
status: "Incomplete",
|
|
||||||
docStatus: "No",
|
|
||||||
pocName: "Maria Gonzalez",
|
|
||||||
pocPhone: "(312) 555-0142",
|
|
||||||
pocNotes: "Week-only target",
|
|
||||||
rescheduleCount: 0,
|
|
||||||
carriedOver: 0,
|
|
||||||
originalWeek: mon,
|
|
||||||
originalDate: "",
|
|
||||||
scheduleWeekOnly: true,
|
|
||||||
mediaCount: 0,
|
|
||||||
}),
|
|
||||||
buildRow({
|
|
||||||
id: "mock-wo-8",
|
|
||||||
woNumber: "20260623008",
|
|
||||||
site: "IND-2208",
|
|
||||||
location: "Indianapolis North",
|
|
||||||
locationId: "mock-loc-2",
|
|
||||||
type: "PM",
|
|
||||||
dispatcherId: String(derek.id),
|
|
||||||
dispatcherName: derek.name,
|
|
||||||
scheduledOn: "",
|
|
||||||
targetWeek: "",
|
|
||||||
dueDate: thu,
|
|
||||||
pm: "HVAC — Quarterly PM",
|
|
||||||
company: "Midwest HVAC Co.",
|
|
||||||
tech: "Mike Torres",
|
|
||||||
techPhone: "(312) 555-0198",
|
|
||||||
apptStart: "",
|
|
||||||
apptEnd: "",
|
|
||||||
apptTime: "",
|
|
||||||
status: "Pending",
|
|
||||||
docStatus: "No",
|
|
||||||
pocName: "Chris Bennett",
|
|
||||||
pocPhone: "(317) 555-2208",
|
|
||||||
pocNotes: "",
|
|
||||||
rescheduleCount: 0,
|
|
||||||
carriedOver: 0,
|
|
||||||
originalWeek: "",
|
|
||||||
originalDate: "",
|
|
||||||
scheduleWeekOnly: false,
|
|
||||||
mediaCount: 0,
|
|
||||||
}),
|
|
||||||
buildRow({
|
|
||||||
id: "mock-wo-9",
|
|
||||||
woNumber: "20260623009",
|
|
||||||
site: "MIL-3315",
|
|
||||||
location: "Milwaukee West",
|
|
||||||
locationId: "mock-loc-3",
|
|
||||||
type: "PM",
|
|
||||||
dispatcherId: String(morgan.id),
|
|
||||||
dispatcherName: morgan.name,
|
|
||||||
...sched(wed),
|
|
||||||
dueDate: fri,
|
|
||||||
pm: "Roof — Membrane patch",
|
|
||||||
company: "SparkLine Electric",
|
|
||||||
tech: "Pat Nguyen",
|
|
||||||
techPhone: "",
|
|
||||||
apptStart: "14:00",
|
|
||||||
apptEnd: "16:00",
|
|
||||||
apptTime: "14:00 – 16:00",
|
|
||||||
status: "En Route",
|
|
||||||
docStatus: "No",
|
|
||||||
pocName: "Taylor Brooks",
|
|
||||||
pocPhone: "(414) 555-3315",
|
|
||||||
pocNotes: "",
|
|
||||||
rescheduleCount: 0,
|
|
||||||
carriedOver: 0,
|
|
||||||
originalWeek: "",
|
|
||||||
originalDate: "",
|
|
||||||
scheduleWeekOnly: false,
|
|
||||||
mediaCount: 0,
|
|
||||||
}),
|
|
||||||
buildRow({
|
|
||||||
id: "mock-wo-10",
|
|
||||||
woNumber: "20260623010",
|
|
||||||
site: "CHI-1042",
|
|
||||||
location: "Chicago Loop",
|
|
||||||
locationId: "mock-loc-1",
|
|
||||||
type: "Reactive",
|
|
||||||
dispatcherId: String(sarah.id),
|
|
||||||
dispatcherName: sarah.name,
|
|
||||||
...sched(tue),
|
|
||||||
dueDate: wed,
|
|
||||||
pm: "Plumbing — Leak repair",
|
|
||||||
company: "Great Lakes Plumbing",
|
|
||||||
tech: "Jen Walsh",
|
|
||||||
techPhone: "(317) 555-4412",
|
|
||||||
apptStart: "11:00",
|
|
||||||
apptEnd: "12:30",
|
|
||||||
apptTime: "11:00 – 12:30",
|
|
||||||
status: "Completed",
|
|
||||||
docStatus: "Yes",
|
|
||||||
pocName: "Maria Gonzalez",
|
|
||||||
pocPhone: "(312) 555-0142",
|
|
||||||
pocNotes: "",
|
|
||||||
rescheduleCount: 0,
|
|
||||||
carriedOver: 0,
|
|
||||||
originalWeek: "",
|
|
||||||
originalDate: "",
|
|
||||||
scheduleWeekOnly: false,
|
|
||||||
mediaCount: 2,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedRows: WorkOrderTableRow[] | null = null;
|
|
||||||
|
|
||||||
export function getMockWorkOrderTableRows(): WorkOrderTableRow[] {
|
|
||||||
if (!cachedRows) {
|
|
||||||
cachedRows = buildMockRows();
|
|
||||||
}
|
|
||||||
return cachedRows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowMatchesSearch(row: WorkOrderTableRow, search: string): boolean {
|
|
||||||
const q = search.trim().toLowerCase();
|
|
||||||
if (!q) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const haystack = [
|
|
||||||
row.site,
|
|
||||||
row.location,
|
|
||||||
row.woNumber,
|
|
||||||
row.type,
|
|
||||||
row.dispatcherName,
|
|
||||||
row.pm,
|
|
||||||
row.company,
|
|
||||||
row.tech,
|
|
||||||
row.status,
|
|
||||||
row.title,
|
|
||||||
]
|
|
||||||
.join(" ")
|
|
||||||
.toLowerCase();
|
|
||||||
return haystack.includes(q);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMockWorkOrdersList(params: WorkOrdersListParams = {}): {
|
|
||||||
items: WorkOrderTableRow[];
|
|
||||||
totalCount: number;
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
} {
|
|
||||||
let items = getMockWorkOrderTableRows();
|
|
||||||
|
|
||||||
if (params.search) {
|
|
||||||
items = items.filter((row) => rowMatchesSearch(row, params.search!));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.type) {
|
|
||||||
items = items.filter((row) => row.type === params.type);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.dateFrom && params.dateTo) {
|
|
||||||
const weekMonday = params.dateFrom;
|
|
||||||
items = items.filter((row) => {
|
|
||||||
if (row.scheduledOn) {
|
|
||||||
return row.scheduledOn >= params.dateFrom! && row.scheduledOn <= params.dateTo!;
|
|
||||||
}
|
|
||||||
if (row.targetWeek) {
|
|
||||||
return isDateInWeek(row.targetWeek, weekMonday);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = params.page ?? 1;
|
|
||||||
const pageSize = params.pageSize ?? items.length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
totalCount: items.length,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMockWorkOrderTableRow(id: string | number): WorkOrderTableRow | undefined {
|
|
||||||
return getMockWorkOrderTableRows().find((row) => String(row.id) === String(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildMockWorkOrderDetailFromTableRow(row: WorkOrderTableRow): WorkOrderDetail {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
internalWONumber: row.woNumber,
|
|
||||||
workOrderNumber: row.woNumber,
|
|
||||||
title: row.title,
|
|
||||||
location: row.location,
|
|
||||||
locationId: String(row.locationId),
|
|
||||||
locationZip: "",
|
|
||||||
assignedTo: row.dispatcherName,
|
|
||||||
dueDate: row.dueDate,
|
|
||||||
createdDate: addDaysIso(todayIso(), -7),
|
|
||||||
createdDateFull: new Date().toISOString(),
|
|
||||||
description: [
|
|
||||||
row.pocNotes && `POC notes: ${row.pocNotes}`,
|
|
||||||
row.pocName && `POC: ${row.pocName} · ${row.pocPhone}`,
|
|
||||||
row.apptTime && row.apptTime !== "—" && `Appt: ${row.apptTime}`,
|
|
||||||
row.company && `Vendor: ${row.company}`,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n"),
|
|
||||||
priority: row.priority,
|
|
||||||
status:
|
|
||||||
row.status === "Completed" ? "Done" : row.status === "Incomplete" ? "Open" : "In Progress",
|
|
||||||
po: "",
|
|
||||||
tt: "",
|
|
||||||
problem: row.pm,
|
|
||||||
trade: "",
|
|
||||||
subTrade: "",
|
|
||||||
vendorNTE: "",
|
|
||||||
scheduledDate: row.scheduledOn,
|
|
||||||
completedDate: row.status === "Completed" ? todayIso() : "",
|
|
||||||
docStatus: row.docStatus,
|
|
||||||
source: row.type,
|
|
||||||
comments: [],
|
|
||||||
auditLog: [],
|
|
||||||
dispatches: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMockWorkOrderLocations(): LocationOption[] {
|
|
||||||
return MOCK_WO_WIZARD_LOCATIONS;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMockWorkOrderVendors(): VendorDropdownItem[] {
|
|
||||||
return MOCK_WO_WIZARD_VENDORS;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMockPmTypes(): string[] {
|
|
||||||
return MOCK_WO_WIZARD_SERVICES.map((s) => s.value).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
@ -98,6 +98,7 @@ export const MOCK_WO_WIZARD_VENDORS: VendorDropdownItem[] = [
|
||||||
{
|
{
|
||||||
id: "mock-vendor-1",
|
id: "mock-vendor-1",
|
||||||
companyName: "Midwest HVAC Co.",
|
companyName: "Midwest HVAC Co.",
|
||||||
|
contactName: "Casey Ortega",
|
||||||
tradeSpecialties: "HVAC",
|
tradeSpecialties: "HVAC",
|
||||||
address: "1200 Industrial Pkwy, Chicago, IL 60632",
|
address: "1200 Industrial Pkwy, Chicago, IL 60632",
|
||||||
distanceMiles: 4.2,
|
distanceMiles: 4.2,
|
||||||
|
|
@ -105,6 +106,7 @@ export const MOCK_WO_WIZARD_VENDORS: VendorDropdownItem[] = [
|
||||||
{
|
{
|
||||||
id: "mock-vendor-2",
|
id: "mock-vendor-2",
|
||||||
companyName: "Great Lakes Plumbing",
|
companyName: "Great Lakes Plumbing",
|
||||||
|
contactName: "Riley Quinn",
|
||||||
tradeSpecialties: "Plumbing",
|
tradeSpecialties: "Plumbing",
|
||||||
address: "455 Water St, Milwaukee, WI 53202",
|
address: "455 Water St, Milwaukee, WI 53202",
|
||||||
distanceMiles: 8.7,
|
distanceMiles: 8.7,
|
||||||
|
|
@ -112,6 +114,7 @@ export const MOCK_WO_WIZARD_VENDORS: VendorDropdownItem[] = [
|
||||||
{
|
{
|
||||||
id: "mock-vendor-3",
|
id: "mock-vendor-3",
|
||||||
companyName: "SparkLine Electric",
|
companyName: "SparkLine Electric",
|
||||||
|
contactName: "Morgan Lee",
|
||||||
tradeSpecialties: "Electrical",
|
tradeSpecialties: "Electrical",
|
||||||
address: "88 Volt Ave, Indianapolis, IN 46225",
|
address: "88 Volt Ave, Indianapolis, IN 46225",
|
||||||
distanceMiles: 12.1,
|
distanceMiles: 12.1,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ export const workOrderFormSchema = z.object({
|
||||||
subTrade: z.string(),
|
subTrade: z.string(),
|
||||||
vendorNTE: z.string(),
|
vendorNTE: z.string(),
|
||||||
source: z.string(),
|
source: z.string(),
|
||||||
|
contactIds: z.array(z.number()).optional(),
|
||||||
|
categoryIds: z.array(z.number()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type WorkOrderFormSchemaValues = z.infer<typeof workOrderFormSchema>;
|
export type WorkOrderFormSchemaValues = z.infer<typeof workOrderFormSchema>;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,22 @@
|
||||||
/** Numeric board DocStatus enum aligned with SHOC board API. */
|
import type { DateRangeKey } from "./work-order-advanced-filters";
|
||||||
|
import type { WorkOrderTableRow } from "./work-order-table-row";
|
||||||
|
import type { WizardWOStatus, WOType } from "./work-order-wizard";
|
||||||
|
|
||||||
|
/** Numeric WorkOrderType enum as expected by POST /workorders/board (no string enums). */
|
||||||
|
export const WORK_ORDER_TYPE_API = {
|
||||||
|
PO: 1,
|
||||||
|
PM: 2,
|
||||||
|
Emergency: 3,
|
||||||
|
Project: 4,
|
||||||
|
Inspection: 5,
|
||||||
|
Reactive: 6,
|
||||||
|
AddOn: 7,
|
||||||
|
Other: 99,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type WorkOrderTypeApi = (typeof WORK_ORDER_TYPE_API)[keyof typeof WORK_ORDER_TYPE_API];
|
||||||
|
|
||||||
|
/** Numeric DocStatus enum. */
|
||||||
export const DOC_STATUS_API = {
|
export const DOC_STATUS_API = {
|
||||||
Yes: 1,
|
Yes: 1,
|
||||||
No: 2,
|
No: 2,
|
||||||
|
|
@ -6,3 +24,229 @@ export const DOC_STATUS_API = {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type DocStatusApi = (typeof DOC_STATUS_API)[keyof typeof DOC_STATUS_API];
|
export type DocStatusApi = (typeof DOC_STATUS_API)[keyof typeof DOC_STATUS_API];
|
||||||
|
|
||||||
|
/** Numeric LifecycleStatus enum aligned with SHOC board. */
|
||||||
|
export const LIFECYCLE_STATUS_API = {
|
||||||
|
Incomplete: 1,
|
||||||
|
Pending: 2,
|
||||||
|
Scheduled: 3,
|
||||||
|
"En Route": 4,
|
||||||
|
"On Site": 5,
|
||||||
|
"In Progress": 6,
|
||||||
|
Completed: 7,
|
||||||
|
Rescheduled: 8,
|
||||||
|
Canceled: 9,
|
||||||
|
"Pending Quote": 10,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type LifecycleStatusApi = (typeof LIFECYCLE_STATUS_API)[keyof typeof LIFECYCLE_STATUS_API];
|
||||||
|
|
||||||
|
export const WORK_ORDER_TYPE_API_TO_FE: Record<number, WOType | ""> = {
|
||||||
|
[WORK_ORDER_TYPE_API.PM]: "PM",
|
||||||
|
[WORK_ORDER_TYPE_API.Reactive]: "Reactive",
|
||||||
|
[WORK_ORDER_TYPE_API.Emergency]: "Emergency",
|
||||||
|
[WORK_ORDER_TYPE_API.AddOn]: "Add-On",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIFECYCLE_STATUS_API_TO_FE: Record<number, WizardWOStatus | "Canceled"> = {
|
||||||
|
[LIFECYCLE_STATUS_API.Incomplete]: "Incomplete",
|
||||||
|
[LIFECYCLE_STATUS_API.Pending]: "Pending",
|
||||||
|
[LIFECYCLE_STATUS_API.Scheduled]: "Scheduled",
|
||||||
|
[LIFECYCLE_STATUS_API["En Route"]]: "En Route",
|
||||||
|
[LIFECYCLE_STATUS_API["On Site"]]: "On Site",
|
||||||
|
[LIFECYCLE_STATUS_API["In Progress"]]: "In Progress",
|
||||||
|
[LIFECYCLE_STATUS_API.Completed]: "Completed",
|
||||||
|
[LIFECYCLE_STATUS_API.Rescheduled]: "Rescheduled",
|
||||||
|
[LIFECYCLE_STATUS_API.Canceled]: "Canceled",
|
||||||
|
[LIFECYCLE_STATUS_API["Pending Quote"]]: "Pending Quote",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface WorkOrderBoardCreatePayload {
|
||||||
|
workOrderType: WorkOrderTypeApi;
|
||||||
|
siteCode: string;
|
||||||
|
woNumber?: string;
|
||||||
|
assignTo?: string;
|
||||||
|
dueDate?: string;
|
||||||
|
scheduledDate?: string;
|
||||||
|
targetWeek?: string;
|
||||||
|
scheduleWeekOnly?: boolean;
|
||||||
|
vendorId?: number;
|
||||||
|
apptDate?: string;
|
||||||
|
apptTime?: string;
|
||||||
|
docStatus?: DocStatusApi;
|
||||||
|
/** Legacy/general text only — do not embed POC/vendor/service notes here. */
|
||||||
|
description?: string;
|
||||||
|
/** @deprecated Prefer `primaryService`. Kept for temporary API compat. */
|
||||||
|
trade?: string;
|
||||||
|
/** Primary service label; persisted as board `pm`. */
|
||||||
|
primaryService?: string;
|
||||||
|
/** Services beyond primary; order preserved. */
|
||||||
|
extraServices?: string[];
|
||||||
|
/** Issue detail from Service & Notes (`pmNote` in draft). */
|
||||||
|
serviceNotes?: string;
|
||||||
|
locationId?: number;
|
||||||
|
pocContactId?: number;
|
||||||
|
pocName?: string;
|
||||||
|
pocPhone?: string;
|
||||||
|
pocNotes?: string;
|
||||||
|
techPhone?: string;
|
||||||
|
vendorNotes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload for PATCH /workorders/{id}/board (one field per request). */
|
||||||
|
export interface WorkOrderBoardPatchPayload {
|
||||||
|
field: string;
|
||||||
|
value?: string | null;
|
||||||
|
workOrderVersion: string;
|
||||||
|
dispatchVersion?: string | null;
|
||||||
|
primaryDispatchId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkOrderBoardEditableField =
|
||||||
|
| "woNumber"
|
||||||
|
| "workOrderType"
|
||||||
|
| "siteCode"
|
||||||
|
| "lifecycleStatus"
|
||||||
|
| "assignTo"
|
||||||
|
| "dueDate"
|
||||||
|
| "scheduledDate"
|
||||||
|
| "targetWeek"
|
||||||
|
| "scheduleWeekOnly"
|
||||||
|
| "vendorId"
|
||||||
|
| "apptDate"
|
||||||
|
| "apptTime"
|
||||||
|
| "docStatus"
|
||||||
|
| "pm"
|
||||||
|
| "serviceNotes"
|
||||||
|
| "extraServices"
|
||||||
|
| "flagColor";
|
||||||
|
|
||||||
|
/** Espelha WorkOrderBoardRowDto (JSON camelCase). */
|
||||||
|
export interface WorkOrderBoardRowApi {
|
||||||
|
id: number;
|
||||||
|
woNumber?: string | null;
|
||||||
|
rescheduleCount?: number;
|
||||||
|
carriedOver?: number;
|
||||||
|
workOrderType?: number | null;
|
||||||
|
isPastDue?: boolean;
|
||||||
|
siteCode?: string | null;
|
||||||
|
locationName?: string | null;
|
||||||
|
pocName?: string | null;
|
||||||
|
pocPhone?: string | null;
|
||||||
|
pocNotes?: string | null;
|
||||||
|
lifecycleStatus?: number | null;
|
||||||
|
lifecycleStatusLabel?: string | null;
|
||||||
|
legacyStatus?: string | null;
|
||||||
|
dispatcherId?: string | null;
|
||||||
|
dispatcherName?: string | null;
|
||||||
|
initials?: string | null;
|
||||||
|
/** Dispatcher avatar color — not the WO flag. */
|
||||||
|
color?: string | null;
|
||||||
|
/** Board flag color (#RRGGBB) or null when cleared. */
|
||||||
|
flagColor?: string | null;
|
||||||
|
dueDate?: string | null;
|
||||||
|
scheduledDate?: string | null;
|
||||||
|
targetWeek?: string | null;
|
||||||
|
scheduleWeekOnly?: boolean | null;
|
||||||
|
dayGroup?: string | null;
|
||||||
|
vendorId?: number | null;
|
||||||
|
vendorName?: string | null;
|
||||||
|
techName?: string | null;
|
||||||
|
techPhone?: string | null;
|
||||||
|
apptDate?: string | null;
|
||||||
|
apptTime?: string | null;
|
||||||
|
pm?: string | null;
|
||||||
|
serviceNotes?: string | null;
|
||||||
|
extraServices?: string[] | null;
|
||||||
|
docStatus?: number | null;
|
||||||
|
primaryDispatchId?: number | null;
|
||||||
|
rowVersion?: string | null;
|
||||||
|
dispatchRowVersion?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkOrderBoardCountsApi {
|
||||||
|
returned: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response de GET /board */
|
||||||
|
export interface WorkOrderBoardResponseApi {
|
||||||
|
weekStart: string;
|
||||||
|
weekEnd: string;
|
||||||
|
counts: WorkOrderBoardCountsApi;
|
||||||
|
unscheduled: WorkOrderBoardRowApi[];
|
||||||
|
scheduled: WorkOrderBoardRowApi[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response de GET /board/search — PagedResult<T> */
|
||||||
|
export interface PagedResultApi<T> {
|
||||||
|
items: T[];
|
||||||
|
totalCount: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages?: number;
|
||||||
|
hasPrevious?: boolean;
|
||||||
|
hasNext?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkOrderBoardSearchResponseApi = PagedResultApi<WorkOrderBoardRowApi>;
|
||||||
|
|
||||||
|
export type BoardDatePresetApi =
|
||||||
|
"ThisWeek" | "LastWeek" | "ThisMonth" | "Last3Months" | "NextWeek" | "NextMonth" | "Custom";
|
||||||
|
|
||||||
|
export const DATE_RANGE_TO_PRESET: Record<DateRangeKey, BoardDatePresetApi> = {
|
||||||
|
"this-week": "ThisWeek",
|
||||||
|
"last-week": "LastWeek",
|
||||||
|
"this-month": "ThisMonth",
|
||||||
|
"last-3-months": "Last3Months",
|
||||||
|
"next-week": "NextWeek",
|
||||||
|
"next-month": "NextMonth",
|
||||||
|
custom: "Custom",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** BE board uses `__unassigned__`; legacy list used `__unassigned`. */
|
||||||
|
export const BOARD_DISPATCHER_UNASSIGNED = "__unassigned__";
|
||||||
|
|
||||||
|
export interface WorkOrderBoardQueryParams {
|
||||||
|
weekStart: string;
|
||||||
|
weekEnd?: string;
|
||||||
|
dispatchers?: string[];
|
||||||
|
myWorkOrders?: boolean;
|
||||||
|
types?: number[];
|
||||||
|
/** Past-due filter; independent of WorkOrderType (Other remains types=99). */
|
||||||
|
overdue?: boolean;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkOrderBoardSearchQueryParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
search?: string;
|
||||||
|
datePreset?: BoardDatePresetApi;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
sites?: string[];
|
||||||
|
types?: number[];
|
||||||
|
/** Past-due filter; independent of WorkOrderType (Other remains types=99). */
|
||||||
|
overdue?: boolean;
|
||||||
|
dispatchers?: string[];
|
||||||
|
statuses?: number[];
|
||||||
|
pmTypes?: string[];
|
||||||
|
vendorIds?: number[];
|
||||||
|
docStatuses?: number[];
|
||||||
|
myWorkOrders?: boolean;
|
||||||
|
sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string;
|
||||||
|
sortDir?: "asc" | "desc" | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkOrderBoardListResult {
|
||||||
|
items: WorkOrderTableRow[];
|
||||||
|
scheduled: WorkOrderTableRow[];
|
||||||
|
unscheduled: WorkOrderTableRow[];
|
||||||
|
totalCount: number;
|
||||||
|
returnedCount: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
weekStart?: string;
|
||||||
|
weekEnd?: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@ export type WorkOrderCommentType = "customer" | "internal";
|
||||||
export interface LocationOption {
|
export interface LocationOption {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
name: string;
|
name: string;
|
||||||
|
siteCode?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmployeeOption {
|
export interface EmployeeOption {
|
||||||
|
|
@ -36,6 +39,7 @@ export interface EmployeeOption {
|
||||||
export interface VendorDropdownItem {
|
export interface VendorDropdownItem {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
|
contactName: string;
|
||||||
tradeSpecialties: string;
|
tradeSpecialties: string;
|
||||||
address: string;
|
address: string;
|
||||||
distanceMiles: number | null;
|
distanceMiles: number | null;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ export interface WorkOrderComment {
|
||||||
commenttext: string;
|
commenttext: string;
|
||||||
commentType: WorkOrderCommentType | string;
|
commentType: WorkOrderCommentType | string;
|
||||||
userName: string;
|
userName: string;
|
||||||
|
/** Author user id from board comments API — used for ownership checks. */
|
||||||
|
authorId?: string | null;
|
||||||
commenter: string | null;
|
commenter: string | null;
|
||||||
recordType: string | null;
|
recordType: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -20,6 +22,9 @@ export interface WorkOrderAuditEntry {
|
||||||
action: string;
|
action: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
|
/** Board detail audit shape: system vs manual (matches design WOSlideOver). */
|
||||||
|
type?: "manual" | "system";
|
||||||
|
dispatcherId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkOrderDetail {
|
export interface WorkOrderDetail {
|
||||||
|
|
@ -48,6 +53,10 @@ export interface WorkOrderDetail {
|
||||||
/** Completion-document status (API DocStatus); persisted via board PATCH. */
|
/** Completion-document status (API DocStatus); persisted via board PATCH. */
|
||||||
docStatus: CompletionDocStatus;
|
docStatus: CompletionDocStatus;
|
||||||
source: string;
|
source: string;
|
||||||
|
/** Present when GetById returns contacts; used to re-send on edit so backend does not wipe. */
|
||||||
|
contactIds?: number[];
|
||||||
|
/** Present when GetById returns categories; used to re-send on edit so backend does not wipe. */
|
||||||
|
categoryIds?: number[];
|
||||||
comments: WorkOrderComment[];
|
comments: WorkOrderComment[];
|
||||||
auditLog: WorkOrderAuditEntry[];
|
auditLog: WorkOrderAuditEntry[];
|
||||||
dispatches: WorkOrderDispatchSummary[];
|
dispatches: WorkOrderDispatchSummary[];
|
||||||
|
|
@ -75,6 +84,10 @@ export interface WorkOrderFormValues {
|
||||||
subTrade: string;
|
subTrade: string;
|
||||||
vendorNTE: string;
|
vendorNTE: string;
|
||||||
source: string;
|
source: string;
|
||||||
|
/** When set (from detail), re-sent on edit to avoid wiping contacts. */
|
||||||
|
contactIds?: number[];
|
||||||
|
/** When set (from detail), re-sent on edit to avoid wiping categories. */
|
||||||
|
categoryIds?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultWorkOrderFormValues(): WorkOrderFormValues {
|
export function createDefaultWorkOrderFormValues(): WorkOrderFormValues {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { WizardWOStatus, WOType } from "./work-order-wizard";
|
||||||
|
|
||||||
export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
|
export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
|
||||||
|
|
||||||
export const WORK_ORDERS_TABLE_PAGE_SIZE = 500;
|
export const WORK_ORDERS_TABLE_PAGE_SIZE = 100;
|
||||||
|
|
||||||
export interface WorkOrderTableRow {
|
export interface WorkOrderTableRow {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
|
|
@ -20,13 +20,18 @@ export interface WorkOrderTableRow {
|
||||||
location: string;
|
location: string;
|
||||||
locationId: string | number;
|
locationId: string | number;
|
||||||
pm: string;
|
pm: string;
|
||||||
|
/** Issue detail from Service & Notes. */
|
||||||
|
serviceNotes: string;
|
||||||
|
/** Additional services beyond primary `pm`. */
|
||||||
|
extraServices: string[];
|
||||||
|
vendorId: string;
|
||||||
company: string;
|
company: string;
|
||||||
tech: string;
|
tech: string;
|
||||||
techPhone: string;
|
techPhone: string;
|
||||||
apptStart: string;
|
apptStart: string;
|
||||||
apptEnd: string;
|
apptEnd: string;
|
||||||
apptTime: string;
|
apptTime: string;
|
||||||
status: WizardWOStatus | "";
|
status: WizardWOStatus | "Canceled" | "";
|
||||||
docStatus: CompletionDocStatus;
|
docStatus: CompletionDocStatus;
|
||||||
pocName: string;
|
pocName: string;
|
||||||
pocPhone: string;
|
pocPhone: string;
|
||||||
|
|
@ -41,6 +46,11 @@ export interface WorkOrderTableRow {
|
||||||
mediaCount: number;
|
mediaCount: number;
|
||||||
title: string;
|
title: string;
|
||||||
priority: string;
|
priority: string;
|
||||||
|
primaryDispatchId: number | null;
|
||||||
|
rowVersion: string;
|
||||||
|
dispatchRowVersion: string;
|
||||||
|
/** Board flag color (#RRGGBB). Null/empty = no flag. Distinct from dispatcher avatar `color`. */
|
||||||
|
flagColor: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkOrderTableDraft {
|
export interface WorkOrderTableDraft {
|
||||||
|
|
@ -54,6 +64,9 @@ export interface WorkOrderTableDraft {
|
||||||
location: string;
|
location: string;
|
||||||
locationId: string | number;
|
locationId: string | number;
|
||||||
pm: string;
|
pm: string;
|
||||||
|
serviceNotes: string;
|
||||||
|
extraServices: string[];
|
||||||
|
vendorId: string;
|
||||||
company: string;
|
company: string;
|
||||||
tech: string;
|
tech: string;
|
||||||
techPhone: string;
|
techPhone: string;
|
||||||
|
|
@ -78,6 +91,9 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
|
||||||
location: "",
|
location: "",
|
||||||
locationId: "",
|
locationId: "",
|
||||||
pm: "",
|
pm: "",
|
||||||
|
serviceNotes: "",
|
||||||
|
extraServices: [],
|
||||||
|
vendorId: "",
|
||||||
company: "",
|
company: "",
|
||||||
tech: "",
|
tech: "",
|
||||||
techPhone: "",
|
techPhone: "",
|
||||||
|
|
@ -91,11 +107,13 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
|
||||||
targetWeek: "",
|
targetWeek: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface WorkOrderDayGroup {
|
export type WorkOrderDayGroup = {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
date: string;
|
date: string;
|
||||||
items: WorkOrderTableRow[];
|
items: WorkOrderTableRow[];
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export type WorkOrderTablePatch = Partial<WorkOrderTableRow>;
|
||||||
|
|
||||||
export const UNSCHEDULED_DAY_KEY = "__unscheduled__" as const;
|
export const UNSCHEDULED_DAY_KEY = "__unscheduled__" as const;
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,12 @@ export const ALL_WIZARD_STATUSES = [
|
||||||
|
|
||||||
export type WizardWOStatus = (typeof ALL_WIZARD_STATUSES)[number];
|
export type WizardWOStatus = (typeof ALL_WIZARD_STATUSES)[number];
|
||||||
|
|
||||||
|
export type WizardSchedMode = "date" | "week" | "range";
|
||||||
|
|
||||||
export interface WorkOrderWizardDraft {
|
export interface WorkOrderWizardDraft {
|
||||||
locationId: string;
|
locationId: string;
|
||||||
locationName: string;
|
locationName: string;
|
||||||
|
siteCode: string;
|
||||||
woNumber: string;
|
woNumber: string;
|
||||||
type: WOType | "";
|
type: WOType | "";
|
||||||
assigneeId: string;
|
assigneeId: string;
|
||||||
|
|
@ -26,6 +29,10 @@ export interface WorkOrderWizardDraft {
|
||||||
scheduledOn: string;
|
scheduledOn: string;
|
||||||
dueDate: string;
|
dueDate: string;
|
||||||
pm: string;
|
pm: string;
|
||||||
|
/** Extra services beyond primary `pm` (Service & Notes). */
|
||||||
|
extraServices: string[];
|
||||||
|
/** Free-text note attached to the service (Service & Notes). */
|
||||||
|
pmNote: string;
|
||||||
vendorId: string;
|
vendorId: string;
|
||||||
vendorName: string;
|
vendorName: string;
|
||||||
techPhone: string;
|
techPhone: string;
|
||||||
|
|
@ -37,11 +44,18 @@ export interface WorkOrderWizardDraft {
|
||||||
pocPhone: string;
|
pocPhone: string;
|
||||||
pocNotes: string;
|
pocNotes: string;
|
||||||
scheduleWeekOnly: boolean;
|
scheduleWeekOnly: boolean;
|
||||||
|
/** Monday ISO when week-only; preferred over deriving from scheduledOn (inline create). */
|
||||||
|
targetWeek?: string;
|
||||||
|
/** End date when Schedule On is a range (ISO; start = scheduledOn). */
|
||||||
|
scheduleEnd?: string;
|
||||||
|
/** Last Schedule On mode used. */
|
||||||
|
schedMode?: WizardSchedMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
||||||
locationId: "",
|
locationId: "",
|
||||||
locationName: "",
|
locationName: "",
|
||||||
|
siteCode: "",
|
||||||
woNumber: "",
|
woNumber: "",
|
||||||
type: "",
|
type: "",
|
||||||
assigneeId: "",
|
assigneeId: "",
|
||||||
|
|
@ -49,6 +63,8 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
||||||
scheduledOn: "",
|
scheduledOn: "",
|
||||||
dueDate: "",
|
dueDate: "",
|
||||||
pm: "",
|
pm: "",
|
||||||
|
extraServices: [],
|
||||||
|
pmNote: "",
|
||||||
vendorId: "",
|
vendorId: "",
|
||||||
vendorName: "",
|
vendorName: "",
|
||||||
techPhone: "",
|
techPhone: "",
|
||||||
|
|
@ -60,8 +76,20 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
scheduleWeekOnly: false,
|
scheduleWeekOnly: false,
|
||||||
|
targetWeek: "",
|
||||||
|
scheduleEnd: "",
|
||||||
|
schedMode: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** WO number is optional; when provided must be digits only, 1–20 characters. */
|
||||||
|
export function isWizardWoNumberValid(woNumber: string): boolean {
|
||||||
|
const trimmed = woNumber.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return /^\d{1,20}$/.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
export const WIZARD_STEPS: { title: string; nav: string; subtitle: string }[] = [
|
export const WIZARD_STEPS: { title: string; nav: string; subtitle: string }[] = [
|
||||||
{
|
{
|
||||||
title: "Type & schedule",
|
title: "Type & schedule",
|
||||||
|
|
@ -131,11 +159,11 @@ export const TIME_SLOTS: string[] = (() => {
|
||||||
})();
|
})();
|
||||||
|
|
||||||
export function canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean {
|
export function canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean {
|
||||||
return Boolean(draft.type);
|
return Boolean(draft.type) && isWizardWoNumberValid(draft.woNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canCreateWorkOrderFromWizard(draft: WorkOrderWizardDraft): boolean {
|
export function canCreateWorkOrderFromWizard(draft: WorkOrderWizardDraft): boolean {
|
||||||
return Boolean(draft.locationId);
|
return Boolean(draft.type && draft.siteCode) && isWizardWoNumberValid(draft.woNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): boolean {
|
export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): boolean {
|
||||||
|
|
@ -143,7 +171,7 @@ export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): bo
|
||||||
return canContinueWizardStep1(draft);
|
return canContinueWizardStep1(draft);
|
||||||
}
|
}
|
||||||
if (step === 2) {
|
if (step === 2) {
|
||||||
return Boolean(draft.locationId);
|
return Boolean(draft.locationId && draft.siteCode);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,31 @@
|
||||||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||||
import { shouldUseMockWoWizardCreate } from "@/domain/work-orders/mocks/is-wo-wizard-mock-enabled";
|
|
||||||
import { buildMockWorkOrderDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
|
|
||||||
import type { WorkOrderFormValues } from "@/domain/work-orders/types/work-order-detail";
|
|
||||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
|
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
async function createWorkOrderFromWizard(values: WorkOrderFormValues): Promise<WorkOrderDetail> {
|
|
||||||
if (shouldUseMockWoWizardCreate(values.locationId)) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
||||||
return buildMockWorkOrderDetail(values);
|
|
||||||
}
|
|
||||||
|
|
||||||
return workOrdersApi.create(values);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateWorkOrderFromWizard(
|
export function useCreateWorkOrderFromWizard(
|
||||||
onSuccess?: () => void,
|
onSuccess?: () => void,
|
||||||
): UseMutationResult<WorkOrderDetail, Error, WorkOrderFormValues> {
|
): UseMutationResult<WorkOrderDetail, Error, WorkOrderWizardDraft> {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: createWorkOrderFromWizard,
|
mutationFn: (draft: WorkOrderWizardDraft) => workOrdersApi.createFromBoard(draft),
|
||||||
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||||
|
|
||||||
toast.success("Work Order created successfully!");
|
toast.success("Work Order created successfully!");
|
||||||
|
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
},
|
},
|
||||||
|
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
toast.error(error.message || "Failed to create work order");
|
toast.error(error.message || "Failed to create work order");
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -39,20 +39,20 @@ export function useSaveWorkOrder() {
|
||||||
values,
|
values,
|
||||||
vendorAssignment,
|
vendorAssignment,
|
||||||
}: SaveWorkOrderInput): Promise<SaveWorkOrderResult> => {
|
}: SaveWorkOrderInput): Promise<SaveWorkOrderResult> => {
|
||||||
const savedWorkOrder = workOrderId
|
const savedWorkOrderId = workOrderId
|
||||||
? await workOrdersApi.update(workOrderId, values)
|
? await workOrdersApi.update(workOrderId, values).then(() => workOrderId)
|
||||||
: await workOrdersApi.create(values);
|
: await workOrdersApi.create(values).then((created) => created.id);
|
||||||
|
|
||||||
let assignment: VendorAssignmentResult | undefined;
|
let assignment: VendorAssignmentResult | undefined;
|
||||||
if (vendorAssignment) {
|
if (vendorAssignment) {
|
||||||
try {
|
try {
|
||||||
assignment = await vendorOperationsApi.assignVendor(savedWorkOrder.id, vendorAssignment);
|
assignment = await vendorOperationsApi.assignVendor(savedWorkOrderId, vendorAssignment);
|
||||||
} catch {
|
} catch {
|
||||||
throw new PartialWorkOrderSaveError(savedWorkOrder.id);
|
throw new PartialWorkOrderSaveError(savedWorkOrderId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { workOrderId: savedWorkOrder.id, assignment };
|
return { workOrderId: savedWorkOrderId, assignment };
|
||||||
},
|
},
|
||||||
onSuccess: ({ workOrderId, assignment }) => {
|
onSuccess: ({ workOrderId, assignment }) => {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||||
import { fetchWorkOrderEmployees } from "@/domain/work-orders/use-cases/fetch-work-order-employees";
|
import { fetchWorkOrderEmployees } from "@/domain/work-orders/use-cases/fetch-work-order-employees";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -20,6 +20,30 @@ export function useWorkOrderDetail(
|
||||||
queryKey: queryKeys.workOrders.detail(id ?? ""),
|
queryKey: queryKeys.workOrders.detail(id ?? ""),
|
||||||
queryFn: () => workOrdersApi.getById(requireQueryParam(id, "Work order id")),
|
queryFn: () => workOrdersApi.getById(requireQueryParam(id, "Work order id")),
|
||||||
enabled: id !== undefined && id !== "",
|
enabled: id !== undefined && id !== "",
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkOrderBoardDetail(
|
||||||
|
id: string | number | undefined,
|
||||||
|
enabled = true,
|
||||||
|
): UseQueryResult<WorkOrderBoardDetail, Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.workOrders.boardDetail(id ?? ""),
|
||||||
|
queryFn: () => workOrdersApi.getBoardDetail(requireQueryParam(id, "Work order id")),
|
||||||
|
enabled: enabled && id !== undefined && id !== "",
|
||||||
|
// Do not keepPreviousData — placeholder detail from another WO leaks into the slide-over.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkOrderMedia(
|
||||||
|
id: string | number | undefined,
|
||||||
|
enabled = true,
|
||||||
|
): UseQueryResult<WorkOrderBoardDetail["media"], Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.workOrders.boardMedia(id ?? ""),
|
||||||
|
queryFn: () => workOrdersApi.listMedia(requireQueryParam(id, "Work order id")),
|
||||||
|
enabled: enabled && id !== undefined && id !== "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,7 +79,7 @@ export function useWorkOrderVendors(
|
||||||
siteZip?: string,
|
siteZip?: string,
|
||||||
): UseQueryResult<VendorDropdownItem[], Error> {
|
): UseQueryResult<VendorDropdownItem[], Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.workOrders.vendors(trade, siteZip),
|
queryKey: queryKeys.vendors.dropdown(trade, siteZip),
|
||||||
queryFn: () => workOrdersApi.fetchVendorsDropdown(trade, siteZip),
|
queryFn: () => workOrdersApi.fetchVendorsDropdown(trade, siteZip),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
@ -76,5 +100,6 @@ export function useDispatchDetail(
|
||||||
queryKey: queryKeys.workOrders.dispatch(dispatchId ?? ""),
|
queryKey: queryKeys.workOrders.dispatch(dispatchId ?? ""),
|
||||||
queryFn: () => workOrdersApi.getDispatchById(requireQueryParam(dispatchId, "Dispatch id")),
|
queryFn: () => workOrdersApi.getDispatchById(requireQueryParam(dispatchId, "Dispatch id")),
|
||||||
enabled: dispatchId !== undefined && dispatchId !== "",
|
enabled: dispatchId !== undefined && dispatchId !== "",
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/r
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||||
|
import { BoardPatchConflictError } from "@/domain/work-orders/errors/board-patch-errors";
|
||||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
|
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -14,7 +15,10 @@ import type {
|
||||||
WorkOrderFormValues,
|
WorkOrderFormValues,
|
||||||
WorkOrderVendorUpdatePayload,
|
WorkOrderVendorUpdatePayload,
|
||||||
} from "@/domain/work-orders/types/work-order";
|
} from "@/domain/work-orders/types/work-order";
|
||||||
import type { WorkOrderBoardMediaItem } from "@/domain/work-orders/types/work-order-board-detail";
|
import type {
|
||||||
|
WorkOrderBoardCompletion,
|
||||||
|
WorkOrderBoardMediaItem,
|
||||||
|
} from "@/domain/work-orders/types/work-order-board-detail";
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
function invalidateWorkOrders(queryClient: ReturnType<typeof useQueryClient>) {
|
function invalidateWorkOrders(queryClient: ReturnType<typeof useQueryClient>) {
|
||||||
|
|
@ -36,7 +40,24 @@ function invalidateBoardDetail(
|
||||||
|
|
||||||
interface AddWorkOrderCommentInput {
|
interface AddWorkOrderCommentInput {
|
||||||
text: string;
|
text: string;
|
||||||
commentType: "customer" | "internal";
|
commentType?: "customer" | "internal";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateWorkOrderCommentInput {
|
||||||
|
commentId: string | number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadWorkOrderMediaInput {
|
||||||
|
file: File;
|
||||||
|
category: UploadableMediaCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadCompletionDocInput {
|
||||||
|
file: File;
|
||||||
|
workOrderVersion: string;
|
||||||
|
signOffName?: string;
|
||||||
|
signOffSignature?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddDispatchCommentInput {
|
export interface AddDispatchCommentInput {
|
||||||
|
|
@ -67,48 +88,6 @@ interface UpdateWorkOrderVendorInput {
|
||||||
payload: WorkOrderVendorUpdatePayload;
|
payload: WorkOrderVendorUpdatePayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UploadWorkOrderMediaInput {
|
|
||||||
file: File;
|
|
||||||
category: UploadableMediaCategory;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUploadWorkOrderMedia(
|
|
||||||
workOrderId: string | number,
|
|
||||||
): UseMutationResult<WorkOrderBoardMediaItem, Error, UploadWorkOrderMediaInput> {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ file, category }: UploadWorkOrderMediaInput) =>
|
|
||||||
workOrdersApi.uploadMedia(workOrderId, file, category),
|
|
||||||
onSuccess: () => {
|
|
||||||
invalidateBoardDetail(queryClient, workOrderId);
|
|
||||||
invalidateWorkOrders(queryClient);
|
|
||||||
toast.success("File uploaded");
|
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
toast.error(error.message || "Failed to upload file");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteWorkOrderMedia(
|
|
||||||
workOrderId: string | number,
|
|
||||||
): UseMutationResult<void, Error, string | number> {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (mediaId: string | number) => workOrdersApi.deleteMedia(workOrderId, mediaId),
|
|
||||||
onSuccess: () => {
|
|
||||||
invalidateBoardDetail(queryClient, workOrderId);
|
|
||||||
invalidateWorkOrders(queryClient);
|
|
||||||
toast.success("File removed");
|
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
toast.error(error.message || "Failed to remove file");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateWorkOrder(): UseMutationResult<
|
export function useCreateWorkOrder(): UseMutationResult<
|
||||||
WorkOrderDetail,
|
WorkOrderDetail,
|
||||||
Error,
|
Error,
|
||||||
|
|
@ -133,7 +112,7 @@ export function useCreateWorkOrder(): UseMutationResult<
|
||||||
|
|
||||||
export function useUpdateWorkOrder(
|
export function useUpdateWorkOrder(
|
||||||
id: string | number,
|
id: string | number,
|
||||||
): UseMutationResult<WorkOrderDetail, Error, WorkOrderFormSchemaValues | WorkOrderFormValues> {
|
): UseMutationResult<void, Error, WorkOrderFormSchemaValues | WorkOrderFormValues> {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
|
@ -178,10 +157,10 @@ export function useAddWorkOrderComment(
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ text, commentType }: AddWorkOrderCommentInput) =>
|
mutationFn: ({ text }: AddWorkOrderCommentInput) =>
|
||||||
workOrdersApi.addComment(workOrderId, text, commentType),
|
workOrdersApi.createComment(workOrderId, text),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
|
invalidateBoardDetail(queryClient, workOrderId);
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
toast.error(error.message || "Failed to add comment");
|
toast.error(error.message || "Failed to add comment");
|
||||||
|
|
@ -189,6 +168,84 @@ export function useAddWorkOrderComment(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUpdateWorkOrderComment(
|
||||||
|
workOrderId: string | number,
|
||||||
|
): UseMutationResult<WorkOrderComment, Error, UpdateWorkOrderCommentInput> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ commentId, text }: UpdateWorkOrderCommentInput) =>
|
||||||
|
workOrdersApi.updateComment(workOrderId, commentId, text),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateBoardDetail(queryClient, workOrderId);
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || "Failed to update comment");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadWorkOrderMedia(
|
||||||
|
workOrderId: string | number,
|
||||||
|
): UseMutationResult<WorkOrderBoardMediaItem, Error, UploadWorkOrderMediaInput> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ file, category }: UploadWorkOrderMediaInput) =>
|
||||||
|
workOrdersApi.uploadMedia(workOrderId, file, category),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateBoardDetail(queryClient, workOrderId);
|
||||||
|
invalidateWorkOrders(queryClient);
|
||||||
|
toast.success("File uploaded");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || "Failed to upload file");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteWorkOrderMedia(
|
||||||
|
workOrderId: string | number,
|
||||||
|
): UseMutationResult<void, Error, string | number> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (mediaId: string | number) => workOrdersApi.deleteMedia(workOrderId, mediaId),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateBoardDetail(queryClient, workOrderId);
|
||||||
|
invalidateWorkOrders(queryClient);
|
||||||
|
toast.success("File removed");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || "Failed to remove file");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadCompletionDoc(
|
||||||
|
workOrderId: string | number,
|
||||||
|
): UseMutationResult<WorkOrderBoardCompletion, Error, UploadCompletionDocInput> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (params: UploadCompletionDocInput) =>
|
||||||
|
workOrdersApi.uploadCompletionDoc(workOrderId, params),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateBoardDetail(queryClient, workOrderId);
|
||||||
|
invalidateWorkOrders(queryClient);
|
||||||
|
toast.success("Completion document uploaded");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
if (error instanceof BoardPatchConflictError) {
|
||||||
|
invalidateBoardDetail(queryClient, workOrderId);
|
||||||
|
toast.error("Document is out of date. Refresh and try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.error(error.message || "Failed to upload completion document");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useChangeWorkOrderStatus(
|
export function useChangeWorkOrderStatus(
|
||||||
workOrderId: string | number,
|
workOrderId: string | number,
|
||||||
): UseMutationResult<void, Error, string> {
|
): UseMutationResult<void, Error, string> {
|
||||||
|
|
@ -319,14 +376,8 @@ export function useAddDispatchSignoff(
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ signoffType, name, signature, signatureMethod }: AddDispatchSignoffInput) =>
|
mutationFn: (input: AddDispatchSignoffInput) =>
|
||||||
workOrdersApi.addDispatchSignoff({
|
workOrdersApi.addDispatchSignoff(dispatchId, input),
|
||||||
dispatchId,
|
|
||||||
signoffType,
|
|
||||||
name,
|
|
||||||
signature,
|
|
||||||
signatureMethod,
|
|
||||||
}),
|
|
||||||
onSuccess: (_data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
|
||||||
toast.success(`${variables.signoffType} sign-off collected`);
|
toast.success(`${variables.signoffType} sign-off collected`);
|
||||||
|
|
|
||||||
|
|
@ -3,113 +3,141 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||||
import {
|
import {
|
||||||
mapApiStatusToWizard,
|
BoardPatchConflictError,
|
||||||
mapWizardStatusToApi,
|
BoardPatchValidationError,
|
||||||
} from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
} from "@/domain/work-orders/errors/board-patch-errors";
|
||||||
|
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
||||||
|
import {
|
||||||
|
buildBoardPatchPayload,
|
||||||
|
expandTablePatchToBoardFieldOps,
|
||||||
|
isLocalOnlyTablePatch,
|
||||||
|
} from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
|
||||||
import {
|
import {
|
||||||
mapDocStatusToApi,
|
|
||||||
mapWorkOrderDetailToFormValues,
|
mapWorkOrderDetailToFormValues,
|
||||||
mapWorkOrderTableRow,
|
mapWorkOrderTableRow,
|
||||||
} from "@/domain/work-orders/mappers/work-order-mapper";
|
} from "@/domain/work-orders/mappers/work-order-mapper";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type {
|
||||||
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
WorkOrderTablePatch,
|
||||||
|
WorkOrderTableRow,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
|
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
|
||||||
import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue";
|
import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue";
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
export type WorkOrderTablePatch = Partial<WorkOrderTableRow>;
|
export type { WorkOrderTablePatch };
|
||||||
|
|
||||||
async function loadFormAndUpdate(
|
export type WorkOrderTablePatchHandlers = {
|
||||||
id: string | number,
|
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
|
||||||
patch: (
|
clearPatch: (id: string | number) => void;
|
||||||
values: ReturnType<typeof mapWorkOrderDetailToFormValues>,
|
getRow?: (id: string | number) => WorkOrderTableRow | undefined;
|
||||||
) => ReturnType<typeof mapWorkOrderDetailToFormValues>,
|
};
|
||||||
) {
|
|
||||||
|
type PatchResult =
|
||||||
|
{ kind: "local"; patch: WorkOrderTablePatch } | { kind: "board"; row: WorkOrderTableRow };
|
||||||
|
|
||||||
|
async function persistCompletedDate(id: string | number, completedDate: string): Promise<void> {
|
||||||
const detail = await workOrdersApi.getById(id);
|
const detail = await workOrdersApi.getById(id);
|
||||||
const values = mapWorkOrderDetailToFormValues(detail);
|
const values = mapWorkOrderDetailToFormValues(detail);
|
||||||
const next = patch(values);
|
await workOrdersApi.update(id, { ...values, completedDate });
|
||||||
await workOrdersApi.update(id, next);
|
|
||||||
return detail;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFullUpdateValues(
|
async function applyBoardFieldOps(
|
||||||
values: ReturnType<typeof mapWorkOrderDetailToFormValues>,
|
|
||||||
patch: WorkOrderTablePatch,
|
|
||||||
): ReturnType<typeof mapWorkOrderDetailToFormValues> {
|
|
||||||
return {
|
|
||||||
...values,
|
|
||||||
workOrderNumber: patch.woNumber ?? values.workOrderNumber,
|
|
||||||
internalWONumber: patch.woNumber ?? values.internalWONumber,
|
|
||||||
locationId: patch.locationId != null ? String(patch.locationId) : values.locationId,
|
|
||||||
dueDate: patch.dueDate ?? values.dueDate,
|
|
||||||
scheduledDate: patch.scheduledOn ?? values.scheduledDate,
|
|
||||||
completedDate: patch.completedDate ?? values.completedDate,
|
|
||||||
assignedTo: patch.dispatcherName ?? values.assignedTo,
|
|
||||||
problem: patch.pm ?? values.problem,
|
|
||||||
source: patch.type ?? values.source,
|
|
||||||
priority: values.priority,
|
|
||||||
status:
|
|
||||||
patch.status !== undefined
|
|
||||||
? mapWizardStatusToApi(patch.status as WizardWOStatus)
|
|
||||||
: values.status,
|
|
||||||
description: [
|
|
||||||
values.description,
|
|
||||||
patch.pocNotes ? `POC notes: ${patch.pocNotes}` : "",
|
|
||||||
patch.pocName || patch.pocPhone
|
|
||||||
? `POC: ${[patch.pocName, patch.pocPhone].filter(Boolean).join(" · ")}`
|
|
||||||
: "",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function persistDocStatus(
|
|
||||||
id: string | number,
|
|
||||||
docStatus: NonNullable<WorkOrderTablePatch["docStatus"]>,
|
|
||||||
) {
|
|
||||||
const workOrderVersion = await workOrdersApi.resolveBoardWorkOrderVersion(id);
|
|
||||||
await workOrdersApi.patchBoardField(id, {
|
|
||||||
field: "docStatus",
|
|
||||||
value: mapDocStatusToApi(docStatus),
|
|
||||||
workOrderVersion,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runWorkOrderPatch(
|
|
||||||
id: string | number,
|
id: string | number,
|
||||||
patch: WorkOrderTablePatch,
|
patch: WorkOrderTablePatch,
|
||||||
useFullUpdate: boolean | undefined,
|
getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined,
|
||||||
): Promise<WorkOrderTablePatch> {
|
): Promise<WorkOrderTableRow> {
|
||||||
if (patch.docStatus !== undefined) {
|
const ops = expandTablePatchToBoardFieldOps(patch);
|
||||||
await persistDocStatus(id, patch.docStatus);
|
if (ops.length === 0) {
|
||||||
const rest: WorkOrderTablePatch = { ...patch };
|
throw new BoardPatchValidationError(
|
||||||
delete rest.docStatus;
|
"NoPersistableFields",
|
||||||
if (Object.keys(rest).length === 0) {
|
"These changes cannot be saved on the board. Refresh and edit a supported field.",
|
||||||
return patch;
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = getRow?.(id);
|
||||||
|
if (!current?.rowVersion) {
|
||||||
|
throw new Error("Missing work order version for board patch. Refresh and retry.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let latest = current;
|
||||||
|
let completedOps = 0;
|
||||||
|
for (const op of ops) {
|
||||||
|
try {
|
||||||
|
latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
|
||||||
|
completedOps += 1;
|
||||||
|
} catch (error) {
|
||||||
|
if (completedOps > 0) {
|
||||||
|
const field = op.field;
|
||||||
|
const partial = new Error(
|
||||||
|
`Schedule update partially saved (${completedOps}/${ops.length} fields). Failed on "${field}": ${
|
||||||
|
error instanceof Error ? error.message : "Unknown error"
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
(partial as Error & { partialRow?: WorkOrderTableRow }).partialRow = latest;
|
||||||
|
throw partial;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
return runWorkOrderPatch(id, rest, useFullUpdate);
|
|
||||||
}
|
}
|
||||||
if (patch.status !== undefined && !useFullUpdate) {
|
|
||||||
const apiStatus = mapWizardStatusToApi(patch.status as WizardWOStatus);
|
return latest;
|
||||||
await workOrdersApi.changeStatus(id, apiStatus);
|
}
|
||||||
return patch;
|
|
||||||
|
/** CompDoc completedDate uses EditWorkorder; board fields use PATCH …/board. */
|
||||||
|
async function runWorkOrderTablePatch(
|
||||||
|
id: string | number,
|
||||||
|
patch: WorkOrderTablePatch,
|
||||||
|
getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined,
|
||||||
|
): Promise<PatchResult> {
|
||||||
|
if (isLocalOnlyTablePatch(patch)) {
|
||||||
|
return { kind: "local", patch };
|
||||||
}
|
}
|
||||||
if (patch.dispatcherId !== undefined && !useFullUpdate) {
|
|
||||||
await workOrdersApi.changeAssignment(id, patch.dispatcherId || "");
|
const completedDate = patch.completedDate;
|
||||||
|
const boardPatch: WorkOrderTablePatch = { ...patch };
|
||||||
|
delete boardPatch.completedDate;
|
||||||
|
|
||||||
|
let boardRow: WorkOrderTableRow | undefined;
|
||||||
|
const hasBoardKeys = Object.keys(boardPatch).length > 0;
|
||||||
|
if (hasBoardKeys && !isLocalOnlyTablePatch(boardPatch)) {
|
||||||
|
boardRow = await applyBoardFieldOps(id, boardPatch, getRow);
|
||||||
|
} else if (!hasBoardKeys && completedDate === undefined) {
|
||||||
|
throw new BoardPatchValidationError(
|
||||||
|
"NoPersistableFields",
|
||||||
|
"These changes cannot be saved on the board. Refresh and edit a supported field.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completedDate !== undefined) {
|
||||||
|
await persistCompletedDate(id, completedDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boardRow) {
|
||||||
return {
|
return {
|
||||||
...patch,
|
kind: "board",
|
||||||
dispatcherName: patch.dispatcherName ?? "",
|
row: completedDate !== undefined ? { ...boardRow, completedDate } : boardRow,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
await loadFormAndUpdate(id, (values) => buildFullUpdateValues(values, patch));
|
|
||||||
return patch;
|
return { kind: "local", patch };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkOrderTableMutations(
|
export function useWorkOrderTableMutations(
|
||||||
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void,
|
onPatchOrHandlers:
|
||||||
|
((id: string | number, patch: WorkOrderTablePatch) => void) | WorkOrderTablePatchHandlers,
|
||||||
|
getRowArg?: (id: string | number) => WorkOrderTableRow | undefined,
|
||||||
) {
|
) {
|
||||||
|
const handlers: WorkOrderTablePatchHandlers =
|
||||||
|
typeof onPatchOrHandlers === "function"
|
||||||
|
? {
|
||||||
|
onPatch: onPatchOrHandlers,
|
||||||
|
clearPatch: () => undefined,
|
||||||
|
getRow: getRowArg,
|
||||||
|
}
|
||||||
|
: onPatchOrHandlers;
|
||||||
|
|
||||||
|
const { onPatch, clearPatch, getRow } = handlers;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const enqueuePatch = useMemo(() => createWorkOrderPatchQueue(), []);
|
const enqueuePatch = useMemo(() => createWorkOrderPatchQueue(), []);
|
||||||
|
|
||||||
|
|
@ -118,29 +146,42 @@ export function useWorkOrderTableMutations(
|
||||||
};
|
};
|
||||||
|
|
||||||
const patchMutation = useMutation({
|
const patchMutation = useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) =>
|
||||||
id,
|
enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)),
|
||||||
patch,
|
|
||||||
useFullUpdate,
|
|
||||||
}: {
|
|
||||||
id: string | number;
|
|
||||||
patch: WorkOrderTablePatch;
|
|
||||||
useFullUpdate?: boolean;
|
|
||||||
}) => enqueuePatch(id, () => runWorkOrderPatch(id, patch, useFullUpdate)),
|
|
||||||
onMutate: async ({ id, patch }) => {
|
onMutate: async ({ id, patch }) => {
|
||||||
onPatch(id, patch);
|
onPatch(id, patch);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: (result, { id }) => {
|
||||||
|
if (result.kind === "board") {
|
||||||
|
onPatch(id, result.row);
|
||||||
|
}
|
||||||
invalidate();
|
invalidate();
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error, { id }) => {
|
||||||
toast.error(error.message || "Failed to update work order");
|
const partialRow = (error as Error & { partialRow?: WorkOrderTableRow }).partialRow;
|
||||||
|
if (partialRow) {
|
||||||
|
onPatch(id, partialRow);
|
||||||
|
toast.error(error.message);
|
||||||
|
invalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof BoardPatchConflictError && error.currentState) {
|
||||||
|
onPatch(id, error.currentState);
|
||||||
|
toast.error(error.message || "Conflict — refreshed with latest data");
|
||||||
|
} else if (error instanceof BoardPatchValidationError) {
|
||||||
|
clearPatch(id);
|
||||||
|
toast.error(error.message);
|
||||||
|
} else {
|
||||||
|
clearPatch(id);
|
||||||
|
toast.error(error.message || "Failed to update work order");
|
||||||
|
}
|
||||||
invalidate();
|
invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const patchField = (id: string | number, patch: WorkOrderTablePatch, useFullUpdate = false) => {
|
const patchField = (id: string | number, patch: WorkOrderTablePatch) => {
|
||||||
patchMutation.mutate({ id, patch, useFullUpdate });
|
patchMutation.mutate({ id, patch });
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -163,6 +204,7 @@ export function tableRowFromDetail(detail: Awaited<ReturnType<typeof workOrdersA
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Optimistic UI only — persistence auto-schedule is handled by the board PATCH API. */
|
||||||
export function maybeAutoSchedulePatch(row: WorkOrderTableRow): WorkOrderTablePatch {
|
export function maybeAutoSchedulePatch(row: WorkOrderTableRow): WorkOrderTablePatch {
|
||||||
const hasDate = !!row.scheduledOn;
|
const hasDate = !!row.scheduledOn;
|
||||||
const hasDispatcher = !!row.dispatcherId;
|
const hasDispatcher = !!row.dispatcherId;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@ import type {
|
||||||
WorkOrdersListResult,
|
WorkOrdersListResult,
|
||||||
WorkOrderUser,
|
WorkOrderUser,
|
||||||
} from "@/domain/work-orders/types/work-order";
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
import type {
|
||||||
|
WorkOrderBoardListResult,
|
||||||
|
WorkOrderBoardQueryParams,
|
||||||
|
WorkOrderBoardSearchQueryParams,
|
||||||
|
} from "@/domain/work-orders/types/work-order-board";
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
export function useWorkOrdersList(
|
export function useWorkOrdersList(
|
||||||
|
|
@ -17,10 +22,34 @@ export function useWorkOrdersList(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useWorkOrdersBoard(
|
||||||
|
params: WorkOrderBoardQueryParams,
|
||||||
|
enabled = true,
|
||||||
|
): UseQueryResult<WorkOrderBoardListResult, Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.workOrders.board(params),
|
||||||
|
queryFn: () => workOrdersApi.getBoard(params),
|
||||||
|
enabled,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkOrdersBoardSearch(
|
||||||
|
params: WorkOrderBoardSearchQueryParams,
|
||||||
|
enabled = true,
|
||||||
|
): UseQueryResult<WorkOrderBoardListResult, Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.workOrders.boardSearch(params),
|
||||||
|
queryFn: () => workOrdersApi.searchBoard(params),
|
||||||
|
enabled,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useWorkOrderUsers(): UseQueryResult<WorkOrderUser[], Error> {
|
export function useWorkOrderUsers(): UseQueryResult<WorkOrderUser[], Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [...queryKeys.workOrders.all, "users"] as const,
|
queryKey: queryKeys.workOrders.dispatchers(),
|
||||||
queryFn: () => workOrdersApi.fetchUsers(),
|
queryFn: () => workOrdersApi.fetchDispatchers(),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
57
src/domain/work-orders/utils/advanced-filter-predicates.ts
Normal file
57
src/domain/work-orders/utils/advanced-filter-predicates.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
|
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { dateRangeFor } from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||||
|
|
||||||
|
type AdvancedFilterPredicate = (
|
||||||
|
row: WorkOrderTableRow,
|
||||||
|
filters: WorkOrderAdvancedFilters,
|
||||||
|
) => boolean;
|
||||||
|
|
||||||
|
const matchesDateRange: AdvancedFilterPredicate = (row, filters) => {
|
||||||
|
const [from, to] = dateRangeFor(filters);
|
||||||
|
return !row.scheduledOn || (row.scheduledOn >= from && row.scheduledOn <= to);
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesSite: AdvancedFilterPredicate = (row, filters) =>
|
||||||
|
!filters.sites.size || filters.sites.has(row.site);
|
||||||
|
|
||||||
|
const matchesType: AdvancedFilterPredicate = (row, filters) => {
|
||||||
|
if (!filters.types.size) return true;
|
||||||
|
const overdueMatch = filters.types.has("Overdue") && row.isPastDue;
|
||||||
|
const typeMatch = filters.types.has(row.type as never);
|
||||||
|
return overdueMatch || typeMatch;
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesDispatcher: AdvancedFilterPredicate = (row, filters) => {
|
||||||
|
if (!filters.dispatchers.size) return true;
|
||||||
|
const unassignedMatch = filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED) && !row.dispatcherId;
|
||||||
|
const idMatch = !!row.dispatcherId && filters.dispatchers.has(row.dispatcherId);
|
||||||
|
return unassignedMatch || idMatch;
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesStatus: AdvancedFilterPredicate = (row, filters) =>
|
||||||
|
!filters.statuses.size || (!!row.status && filters.statuses.has(row.status as never));
|
||||||
|
|
||||||
|
const matchesPmType: AdvancedFilterPredicate = (row, filters) =>
|
||||||
|
!filters.pmTypes.size || filters.pmTypes.has(row.pm);
|
||||||
|
|
||||||
|
const matchesVendorTech: AdvancedFilterPredicate = (row, filters) =>
|
||||||
|
!filters.vendorTechs.size ||
|
||||||
|
filters.vendorTechs.has(row.tech) ||
|
||||||
|
filters.vendorTechs.has(row.company);
|
||||||
|
|
||||||
|
const matchesDoc: AdvancedFilterPredicate = (row, filters) =>
|
||||||
|
!filters.docs.size || filters.docs.has(row.docStatus as never);
|
||||||
|
|
||||||
|
/** One predicate per filter dimension; a row matches when every predicate is true. */
|
||||||
|
export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [
|
||||||
|
matchesDateRange,
|
||||||
|
matchesSite,
|
||||||
|
matchesType,
|
||||||
|
matchesDispatcher,
|
||||||
|
matchesStatus,
|
||||||
|
matchesPmType,
|
||||||
|
matchesVendorTech,
|
||||||
|
matchesDoc,
|
||||||
|
];
|
||||||
109
src/domain/work-orders/utils/board-audit-action-rules.ts
Normal file
109
src/domain/work-orders/utils/board-audit-action-rules.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order-detail";
|
||||||
|
|
||||||
|
export type AuditActionSource = Pick<
|
||||||
|
WorkOrderAuditEntry,
|
||||||
|
"action" | "fieldName" | "oldValue" | "newValue"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type FormattedAuditValue = {
|
||||||
|
text: string;
|
||||||
|
/** Per-item quotes already applied; omit outer quotes in sentence templates. */
|
||||||
|
isQuotedList: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function wrapAuditValue(value: FormattedAuditValue): string {
|
||||||
|
if (!value.text) return value.text;
|
||||||
|
return value.isQuotedList ? value.text : `"${value.text}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuditActionRuleContext = {
|
||||||
|
entry: AuditActionSource;
|
||||||
|
fieldKey: string;
|
||||||
|
actionKey: string;
|
||||||
|
oldValue: FormattedAuditValue;
|
||||||
|
newValue: FormattedAuditValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuditActionRule = (ctx: AuditActionRuleContext) => string | null;
|
||||||
|
|
||||||
|
function tryStatusRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||||
|
const matches =
|
||||||
|
fieldKey.includes("status") || fieldKey === "lifecyclestatus" || actionKey.includes("status");
|
||||||
|
if (!matches || !newValue.text) return null;
|
||||||
|
|
||||||
|
return `Changed status to ${wrapAuditValue(newValue)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryApptRule({
|
||||||
|
fieldKey,
|
||||||
|
actionKey,
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
}: AuditActionRuleContext): string | null {
|
||||||
|
const matches =
|
||||||
|
fieldKey.includes("appt") || fieldKey.includes("appointment") || actionKey.includes("appt");
|
||||||
|
if (!matches) return null;
|
||||||
|
|
||||||
|
if (oldValue.text && newValue.text) {
|
||||||
|
return `Changed appt time from ${wrapAuditValue(oldValue)} to ${wrapAuditValue(newValue)}`;
|
||||||
|
}
|
||||||
|
if (newValue.text) return `Changed appt time to ${wrapAuditValue(newValue)}`;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryScheduleRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||||
|
const matches =
|
||||||
|
fieldKey.includes("scheduled") ||
|
||||||
|
fieldKey === "schedule" ||
|
||||||
|
fieldKey.includes("scheduledate") ||
|
||||||
|
fieldKey.includes("duedate") ||
|
||||||
|
actionKey === "rescheduled" ||
|
||||||
|
actionKey.includes("reschedule");
|
||||||
|
if (!matches) return null;
|
||||||
|
|
||||||
|
if (newValue.text) return `Changed schedule to ${wrapAuditValue(newValue)}`;
|
||||||
|
|
||||||
|
return "Rescheduled work order";
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryCarriedOverRule({ fieldKey, actionKey, entry }: AuditActionRuleContext): string | null {
|
||||||
|
const matches =
|
||||||
|
fieldKey.includes("carriedover") ||
|
||||||
|
actionKey === "weekrolled" ||
|
||||||
|
actionKey.includes("carryover");
|
||||||
|
if (!matches) return null;
|
||||||
|
|
||||||
|
const increment = Number(entry.newValue?.trim() ?? "");
|
||||||
|
if (Number.isFinite(increment) && increment > 0) {
|
||||||
|
return `Carried over to the next week (increment #${increment})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Carried over to the next week";
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryPastDueRule({ fieldKey, actionKey }: AuditActionRuleContext): string | null {
|
||||||
|
const matches =
|
||||||
|
fieldKey.includes("pastdue") || actionKey.includes("pastdue") || actionKey === "markedpastdue";
|
||||||
|
if (!matches) return null;
|
||||||
|
|
||||||
|
return "WO marked as Past Due. Scheduled date has passed without completion.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryCreatedRule({ fieldKey, actionKey }: AuditActionRuleContext): string | null {
|
||||||
|
const matches =
|
||||||
|
fieldKey === "created" || actionKey === "workordercreated" || actionKey === "created";
|
||||||
|
if (!matches) return null;
|
||||||
|
|
||||||
|
return "Work order created in the system.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applied in order; the first non-null result wins. */
|
||||||
|
export const BOARD_AUDIT_ACTION_RULES: AuditActionRule[] = [
|
||||||
|
tryStatusRule,
|
||||||
|
tryApptRule,
|
||||||
|
tryScheduleRule,
|
||||||
|
tryCarriedOverRule,
|
||||||
|
tryPastDueRule,
|
||||||
|
tryCreatedRule,
|
||||||
|
];
|
||||||
177
src/domain/work-orders/utils/board-query-params.ts
Normal file
177
src/domain/work-orders/utils/board-query-params.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
|
||||||
|
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
|
import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
|
||||||
|
import {
|
||||||
|
BOARD_DISPATCHER_UNASSIGNED,
|
||||||
|
DATE_RANGE_TO_PRESET,
|
||||||
|
DOC_STATUS_API,
|
||||||
|
LIFECYCLE_STATUS_API,
|
||||||
|
type WorkOrderBoardQueryParams,
|
||||||
|
type WorkOrderBoardSearchQueryParams,
|
||||||
|
} from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import { mapWizardTypeToApi } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
|
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
|
||||||
|
import type { CompletionDocFilter } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
|
import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
export const BOARD_SEARCH_MAX_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
function toBoardDispatcherId(id: string): string {
|
||||||
|
if (id === ASSIGNEE_FILTER_UNASSIGNED || id === BOARD_DISPATCHER_UNASSIGNED) {
|
||||||
|
return BOARD_DISPATCHER_UNASSIGNED;
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDispatchers(ids: Iterable<string>): string[] | undefined {
|
||||||
|
const list = [...ids].map(toBoardDispatcherId);
|
||||||
|
return list.length > 0 ? list : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps UI type filters to API `types` + separate `overdue` flag. Overdue is never sent as types=99. */
|
||||||
|
function mapWoTypeFilters(types: Iterable<WOType | string>): {
|
||||||
|
types?: number[];
|
||||||
|
overdue?: boolean;
|
||||||
|
} {
|
||||||
|
const list: number[] = [];
|
||||||
|
let overdue = false;
|
||||||
|
for (const type of types) {
|
||||||
|
if (type === "All") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (type === "Overdue") {
|
||||||
|
overdue = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const api = mapWizardTypeToApi(type as WOType);
|
||||||
|
if (api != null) {
|
||||||
|
list.push(api);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
types: list.length > 0 ? list : undefined,
|
||||||
|
overdue: overdue || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStatuses(statuses: Iterable<WizardWOStatus | string>): number[] | undefined {
|
||||||
|
const list: number[] = [];
|
||||||
|
for (const status of statuses) {
|
||||||
|
const value = LIFECYCLE_STATUS_API[status as WizardWOStatus];
|
||||||
|
if (typeof value === "number") {
|
||||||
|
list.push(value);
|
||||||
|
} else if (status === "Canceled" || status === "Cancelled") {
|
||||||
|
list.push(LIFECYCLE_STATUS_API.Canceled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list.length > 0 ? list : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDocStatuses(docs: Iterable<CompletionDocFilter>): number[] | undefined {
|
||||||
|
const list: number[] = [];
|
||||||
|
for (const doc of docs) {
|
||||||
|
if (doc === "Yes") list.push(DOC_STATUS_API.Yes);
|
||||||
|
else if (doc === "No") list.push(DOC_STATUS_API.No);
|
||||||
|
else if (doc === "NN") list.push(DOC_STATUS_API.NotNeeded);
|
||||||
|
}
|
||||||
|
return list.length > 0 ? list : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build GET /board query from weekly filter bar. */
|
||||||
|
export function toBoardQueryParams(
|
||||||
|
bar: WorkOrderBarFilters,
|
||||||
|
search: string,
|
||||||
|
): WorkOrderBoardQueryParams {
|
||||||
|
const [, weekTo] = weekRange(bar.weekMonday);
|
||||||
|
const typeFilters = bar.type !== "All" ? mapWoTypeFilters([bar.type]) : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
weekStart: bar.weekMonday,
|
||||||
|
weekEnd: weekTo,
|
||||||
|
dispatchers: mapDispatchers(bar.dispatcherIds),
|
||||||
|
types: typeFilters.types,
|
||||||
|
overdue: typeFilters.overdue,
|
||||||
|
search: search.trim().length >= 2 ? search.trim() : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build GET /board/search query from advanced filters (+ optional bar/search). */
|
||||||
|
export function toBoardSearchQueryParams(input: {
|
||||||
|
filters: WorkOrderAdvancedFilters;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortDir?: string;
|
||||||
|
/** Optional company-name → vendorId resolution. */
|
||||||
|
vendorIdsByCompanyName?: Map<string, number>;
|
||||||
|
}): WorkOrderBoardSearchQueryParams {
|
||||||
|
const { filters } = input;
|
||||||
|
const datePreset = DATE_RANGE_TO_PRESET[filters.dateRange];
|
||||||
|
const pageSize = Math.min(
|
||||||
|
input.pageSize ?? BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||||
|
BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||||
|
);
|
||||||
|
|
||||||
|
const vendorIds: number[] = [];
|
||||||
|
if (input.vendorIdsByCompanyName && filters.vendorTechs.size > 0) {
|
||||||
|
for (const name of filters.vendorTechs) {
|
||||||
|
const id = input.vendorIdsByCompanyName.get(name);
|
||||||
|
if (typeof id === "number") {
|
||||||
|
vendorIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeFilters = mapWoTypeFilters(filters.types);
|
||||||
|
|
||||||
|
const params: WorkOrderBoardSearchQueryParams = {
|
||||||
|
page: input.page ?? 0,
|
||||||
|
pageSize,
|
||||||
|
search:
|
||||||
|
input.search?.trim() && input.search.trim().length >= 2 ? input.search.trim() : undefined,
|
||||||
|
datePreset,
|
||||||
|
sites: filters.sites.size > 0 ? [...filters.sites] : undefined,
|
||||||
|
types: typeFilters.types,
|
||||||
|
overdue: typeFilters.overdue,
|
||||||
|
dispatchers: mapDispatchers(filters.dispatchers),
|
||||||
|
statuses: mapStatuses(filters.statuses),
|
||||||
|
pmTypes: filters.pmTypes.size > 0 ? [...filters.pmTypes] : undefined,
|
||||||
|
vendorIds: vendorIds.length > 0 ? vendorIds : undefined,
|
||||||
|
docStatuses: mapDocStatuses(filters.docs),
|
||||||
|
sortBy: input.sortBy || "scheduledDate",
|
||||||
|
sortDir: input.sortDir === "desc" ? "desc" : "asc",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (datePreset === "Custom") {
|
||||||
|
params.dateFrom = filters.customFrom || undefined;
|
||||||
|
params.dateTo = filters.customTo || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ky/URLSearchParams-friendly object; arrays become repeated keys. */
|
||||||
|
export function buildBoardSearchParamsRecord(
|
||||||
|
params: WorkOrderBoardQueryParams | WorkOrderBoardSearchQueryParams,
|
||||||
|
): Record<string, string | number | boolean | (string | number)[]> {
|
||||||
|
const out: Record<string, string | number | boolean | (string | number)[]> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) continue;
|
||||||
|
out[key] = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out[key] = value as string | number | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function woTypeFilterIsOverdue(type: string): boolean {
|
||||||
|
return type === "Overdue";
|
||||||
|
}
|
||||||
156
src/domain/work-orders/utils/board-row-order-storage.ts
Normal file
156
src/domain/work-orders/utils/board-row-order-storage.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
export type BoardRowOrderByDay = Record<string, string[]>;
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = "shoc:wo-board-row-order:v1:";
|
||||||
|
|
||||||
|
export function boardRowOrderStorageKey(weekMonday: string): string {
|
||||||
|
return `${STORAGE_PREFIX}${weekMonday}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStringArray(value: unknown): value is string[] {
|
||||||
|
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBoardRowOrderByDay(value: unknown): value is BoardRowOrderByDay {
|
||||||
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Object.values(value).every(isStringArray);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadBoardRowOrder(weekMonday: string): BoardRowOrderByDay {
|
||||||
|
if (typeof window === "undefined" || !weekMonday) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(boardRowOrderStorageKey(weekMonday));
|
||||||
|
if (!raw) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
return isBoardRowOrderByDay(parsed) ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveBoardRowOrder(weekMonday: string, orderByDay: BoardRowOrderByDay): void {
|
||||||
|
if (typeof window === "undefined" || !weekMonday) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(boardRowOrderStorageKey(weekMonday), JSON.stringify(orderByDay));
|
||||||
|
} catch {
|
||||||
|
// Quota exceeded or storage blocked — keep in-memory order only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reorder `items` so known ids follow `orderedIds`; unknown ids stay at the end (stable). */
|
||||||
|
export function applyDayRowOrder<T extends { id: string | number }>(
|
||||||
|
items: T[],
|
||||||
|
orderedIds: string[] | undefined,
|
||||||
|
): T[] {
|
||||||
|
if (!orderedIds?.length || items.length <= 1) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderMap = new Map(orderedIds.map((id, idx) => [id, idx]));
|
||||||
|
return [...items].sort((a, b) => {
|
||||||
|
const ai = orderMap.get(String(a.id));
|
||||||
|
const bi = orderMap.get(String(b.id));
|
||||||
|
if (ai === undefined && bi === undefined) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (ai === undefined) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (bi === undefined) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return ai - bi;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply saved per-day orders across a flat row list (preserves relative order across days). */
|
||||||
|
export function applyRowOrderByDay<T extends { id: string | number; dayGroup: string }>(
|
||||||
|
items: T[],
|
||||||
|
orderByDay: BoardRowOrderByDay,
|
||||||
|
): T[] {
|
||||||
|
if (!items.length || !Object.keys(orderByDay).length) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byDay = new Map<string, T[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const group = byDay.get(item.dayGroup);
|
||||||
|
if (group) {
|
||||||
|
group.push(item);
|
||||||
|
} else {
|
||||||
|
byDay.set(item.dayGroup, [item]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedByDay = new Map<string, T[]>();
|
||||||
|
for (const [dayGroup, dayItems] of byDay) {
|
||||||
|
orderedByDay.set(dayGroup, applyDayRowOrder(dayItems, orderByDay[dayGroup]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextIndex = new Map<string, number>();
|
||||||
|
return items.map((item) => {
|
||||||
|
const dayItems = orderedByDay.get(item.dayGroup)!;
|
||||||
|
const idx = nextIndex.get(item.dayGroup) ?? 0;
|
||||||
|
nextIndex.set(item.dayGroup, idx + 1);
|
||||||
|
return dayItems[idx]!;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reorderIdsInList(ids: string[], dragId: string, overId: string): string[] | null {
|
||||||
|
const dragIdx = ids.indexOf(dragId);
|
||||||
|
const overIdx = ids.indexOf(overId);
|
||||||
|
if (dragIdx < 0 || overIdx < 0 || dragIdx === overIdx) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const next = [...ids];
|
||||||
|
next.splice(dragIdx, 1);
|
||||||
|
next.splice(overIdx, 0, dragId);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove an id from every day bucket (used when a row changes dayGroup). */
|
||||||
|
export function removeIdFromRowOrder(
|
||||||
|
orderByDay: BoardRowOrderByDay,
|
||||||
|
id: string,
|
||||||
|
): BoardRowOrderByDay {
|
||||||
|
let changed = false;
|
||||||
|
const next: BoardRowOrderByDay = {};
|
||||||
|
for (const [dayGroup, ids] of Object.entries(orderByDay)) {
|
||||||
|
if (!ids.includes(id)) {
|
||||||
|
next[dayGroup] = ids;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
const filtered = ids.filter((item) => item !== id);
|
||||||
|
if (filtered.length) {
|
||||||
|
next[dayGroup] = filtered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : orderByDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge a visible day's new order into stored order: visible ids take the new sequence,
|
||||||
|
* ids that were stored but not currently visible stay after the visible block (preserving
|
||||||
|
* their prior relative order).
|
||||||
|
*/
|
||||||
|
export function mergeDayOrder(
|
||||||
|
previousDayIds: string[] | undefined,
|
||||||
|
visibleOrderedIds: string[],
|
||||||
|
): string[] {
|
||||||
|
if (!previousDayIds?.length) {
|
||||||
|
return visibleOrderedIds;
|
||||||
|
}
|
||||||
|
const visibleSet = new Set(visibleOrderedIds);
|
||||||
|
const hidden = previousDayIds.filter((id) => !visibleSet.has(id));
|
||||||
|
return [...visibleOrderedIds, ...hidden];
|
||||||
|
}
|
||||||
11
src/domain/work-orders/utils/comment-ownership.ts
Normal file
11
src/domain/work-orders/utils/comment-ownership.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import type { WorkOrderComment } from "@/domain/work-orders/types/work-order-detail";
|
||||||
|
|
||||||
|
export function isOwnWorkOrderComment(
|
||||||
|
comment: WorkOrderComment,
|
||||||
|
currentUserId: string | number | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (currentUserId == null || currentUserId === "") return false;
|
||||||
|
const authorId = comment.authorId;
|
||||||
|
if (authorId == null || authorId === "") return false;
|
||||||
|
return String(authorId) === String(currentUserId);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue