mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-03 02:13:22 +00:00
Merge pull request #287 from Sea-Haven-Industries/fix/ab/sh413-services-registry-thr_9fpik7tdmw
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
SH-413: use registry Services in work-order assignment and filters
This commit is contained in:
commit
9fbc166d2f
57 changed files with 3669 additions and 398 deletions
|
|
@ -12,6 +12,19 @@ const TEST_USER = {
|
|||
id: "1",
|
||||
};
|
||||
|
||||
const SERVICE_CATALOG = [
|
||||
{
|
||||
id: 1,
|
||||
name: "HVAC",
|
||||
trade: "HVAC",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: true,
|
||||
supportedWorkOrderTypes: ["PM"],
|
||||
},
|
||||
];
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
|
@ -39,6 +52,9 @@ async function mockWorkOrderApis(page: Page, searchUrls: string[]) {
|
|||
if (pathname.includes("/workorders/board") || pathname.includes("DropdownOptions")) {
|
||||
return route.fallback();
|
||||
}
|
||||
if (pathname === "/api/services") {
|
||||
return fulfillJson(route, { data: SERVICE_CATALOG });
|
||||
}
|
||||
return fulfillJson(route, { data: [] });
|
||||
});
|
||||
await page.route("**/api/**/DropdownOptions/ByCategory/**", (route) => {
|
||||
|
|
@ -136,6 +152,7 @@ test.describe("Work Orders advanced filters", () => {
|
|||
await sheet.getByRole("button", { name: /Indicators/i }).click();
|
||||
await sheet.getByRole("button", { name: /^Add-On$/i }).click();
|
||||
await sheet.getByRole("button", { name: /^Service/i }).click();
|
||||
await expect(sheet.getByText("HVAC", { exact: true })).toBeVisible();
|
||||
await sheet.getByText("HVAC").click();
|
||||
await sheet.getByRole("button", { name: /Completion doc/i }).click();
|
||||
await sheet.getByRole("button", { name: /^Pending$/i }).click();
|
||||
|
|
|
|||
|
|
@ -37,6 +37,29 @@ const VENDORS = [
|
|||
},
|
||||
];
|
||||
|
||||
const SERVICES = [
|
||||
{
|
||||
id: 1,
|
||||
name: "HVAC",
|
||||
trade: "HVAC",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: true,
|
||||
supportedWorkOrderTypes: ["PM"],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Plumbing",
|
||||
trade: "Plumbing",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: true,
|
||||
supportedWorkOrderTypes: ["PM"],
|
||||
},
|
||||
];
|
||||
|
||||
const scheduledRow = {
|
||||
id: 1,
|
||||
woNumber: "WO-501",
|
||||
|
|
@ -150,6 +173,11 @@ async function mockWorkOrderApi(page: Page, mode: "default" | "empty" | "error"
|
|||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/api/services") {
|
||||
await fulfillJson(route, { data: SERVICES });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.includes("/DropdownOptions/ByCategory/Problem")) {
|
||||
await fulfillJson(route, {
|
||||
data: [
|
||||
|
|
@ -192,7 +220,18 @@ async function mockWorkOrderApi(page: Page, mode: "default" | "empty" | "error"
|
|||
|
||||
async function openWorkOrderPage(page: Page, mode: "default" | "empty" | "error" = "default") {
|
||||
await mockWorkOrderApi(page, mode);
|
||||
const registryResponses = Promise.all([
|
||||
page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === "/api/services" && url.searchParams.get("isActive") === "true";
|
||||
}),
|
||||
page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === "/api/services" && !url.searchParams.has("isActive");
|
||||
}),
|
||||
]);
|
||||
await page.goto("/workorders");
|
||||
await registryResponses;
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
} from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import {
|
||||
effectiveCompany,
|
||||
|
|
@ -31,6 +32,7 @@ type SlideOverInfoTabDisplayViewProps = {
|
|||
sites: LocationOption[];
|
||||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
services?: Service[];
|
||||
};
|
||||
|
||||
type FrozenPoc = ReturnType<typeof effectivePoc>;
|
||||
|
|
@ -115,14 +117,22 @@ function SlideOverSiteField({
|
|||
return <SlideOverCompletedSite source={source} poc={poc} sites={sites} />;
|
||||
}
|
||||
|
||||
function SlideOverOpenService({ source }: { source: WorkOrderTableRow }) {
|
||||
function SlideOverOpenService({
|
||||
source,
|
||||
services,
|
||||
}: {
|
||||
source: WorkOrderTableRow;
|
||||
services: Service[];
|
||||
}) {
|
||||
if (!source.pm) {
|
||||
return <span style={{ color: "var(--color-text-muted)" }}>—</span>;
|
||||
}
|
||||
const linkedService = services.find((service) => String(service.id) === String(source.serviceId));
|
||||
const name = linkedService?.isActive ? linkedService.name : source.pm;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
|
||||
<PmTypeIcon pmType={source.pm} size={14} />
|
||||
{source.pm}
|
||||
<PmTypeIcon pmType={name} size={14} />
|
||||
{name}
|
||||
{Boolean(source.avetaRequired) && (
|
||||
<AvetaBadge size={11} attached={Boolean(source.hasAvetaDocument)} />
|
||||
)}
|
||||
|
|
@ -134,16 +144,21 @@ function SlideOverServiceField({
|
|||
source,
|
||||
isCompleted,
|
||||
pmTypes,
|
||||
services,
|
||||
}: {
|
||||
source: WorkOrderTableRow;
|
||||
isCompleted: boolean;
|
||||
pmTypes: string[];
|
||||
services: Service[];
|
||||
}) {
|
||||
if (!isCompleted) return <SlideOverOpenService source={source} />;
|
||||
if (!isCompleted) return <SlideOverOpenService source={source} services={services} />;
|
||||
return (
|
||||
<PMTypeCell
|
||||
value={source.pm}
|
||||
pmTypes={pmTypes}
|
||||
services={services}
|
||||
serviceId={source.serviceId}
|
||||
workOrderType={source.type}
|
||||
serviceNotes={source.serviceNotes}
|
||||
extraServices={source.extraServices}
|
||||
avetaRequired={source.avetaRequired}
|
||||
|
|
@ -189,6 +204,7 @@ export function SlideOverInfoTabDisplayView({
|
|||
sites,
|
||||
vendors,
|
||||
pmTypes,
|
||||
services = [],
|
||||
}: SlideOverInfoTabDisplayViewProps) {
|
||||
const poc = effectivePoc(source);
|
||||
|
||||
|
|
@ -219,7 +235,12 @@ export function SlideOverInfoTabDisplayView({
|
|||
<span className="text-[12.5px]">{formatApptDisplay(source.apptTime)}</span>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Service">
|
||||
<SlideOverServiceField source={source} isCompleted={isCompleted} pmTypes={pmTypes} />
|
||||
<SlideOverServiceField
|
||||
source={source}
|
||||
isCompleted={isCompleted}
|
||||
pmTypes={pmTypes}
|
||||
services={services}
|
||||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Vendor">
|
||||
<SlideOverVendorField source={source} isCompleted={isCompleted} vendors={vendors} />
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ import type {
|
|||
} from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
import { serviceSupportsWorkOrderType } from "@/domain/services/utils/services-for-work-order-type";
|
||||
import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
|
||||
import { DocBadge } from "./slide-over-doc-badge";
|
||||
|
|
@ -30,12 +34,102 @@ type SlideOverInfoTabEditViewProps = {
|
|||
sites: LocationOption[];
|
||||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
services?: Service[];
|
||||
serviceHistory?: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryReady?: boolean;
|
||||
allOrders: WorkOrderTableRow[];
|
||||
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
onRequestComplete?: () => void;
|
||||
};
|
||||
|
||||
type SlideOverServiceFieldProps = Pick<
|
||||
SlideOverInfoTabEditViewProps,
|
||||
"draft" | "pmTypes" | "services" | "serviceQuery" | "onDraftChange"
|
||||
>;
|
||||
|
||||
function SlideOverServiceField({
|
||||
draft,
|
||||
pmTypes,
|
||||
services = [],
|
||||
serviceQuery,
|
||||
onDraftChange,
|
||||
}: SlideOverServiceFieldProps) {
|
||||
return (
|
||||
<SlideOverInfoRow label="Service">
|
||||
<PMTypeCell
|
||||
value={draft.pm}
|
||||
pmTypes={pmTypes}
|
||||
services={services}
|
||||
serviceQuery={serviceQuery}
|
||||
serviceId={draft.serviceId}
|
||||
workOrderType={draft.type}
|
||||
serviceNotes={draft.serviceNotes}
|
||||
extraServices={draft.extraServices}
|
||||
avetaRequired={draft.avetaRequired}
|
||||
woNumber={draft.woNumber}
|
||||
onSave={(pm, id) => onDraftChange(id != null ? { pm, serviceId: id } : { pm })}
|
||||
onSaveDetails={(details) =>
|
||||
onDraftChange({
|
||||
...serviceIdentityPatch(draft.serviceId, draft.pm, details.pm, details.serviceId),
|
||||
serviceNotes: details.serviceNotes,
|
||||
extraServices: details.extraServices,
|
||||
avetaRequired: details.avetaRequired,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SlideOverInfoRow>
|
||||
);
|
||||
}
|
||||
|
||||
type SlideOverTypeFieldProps = Pick<
|
||||
SlideOverInfoTabEditViewProps,
|
||||
| "draft"
|
||||
| "services"
|
||||
| "serviceHistory"
|
||||
| "serviceHistoryReady"
|
||||
| "serviceHistoryQuery"
|
||||
| "onDraftChange"
|
||||
>;
|
||||
|
||||
function SlideOverTypeField({
|
||||
draft,
|
||||
services = [],
|
||||
serviceHistory,
|
||||
serviceHistoryReady = true,
|
||||
serviceHistoryQuery,
|
||||
onDraftChange,
|
||||
}: SlideOverTypeFieldProps) {
|
||||
const typeServices = serviceHistory ?? services;
|
||||
const linkedService = typeServices.find(
|
||||
(service) => String(service.id) === String(draft.serviceId),
|
||||
);
|
||||
const registryUnavailable = draft.serviceId != null && !serviceHistoryReady;
|
||||
const linkedServiceMissing =
|
||||
draft.serviceId != null && serviceHistoryReady && linkedService == null;
|
||||
|
||||
return (
|
||||
<TypeCell
|
||||
value={draft.type}
|
||||
severity={draft.severity}
|
||||
registryUnavailable={registryUnavailable}
|
||||
registryMissing={linkedServiceMissing}
|
||||
serviceHistoryQuery={serviceHistoryQuery}
|
||||
onSave={(type, severity) => {
|
||||
if (registryUnavailable) return;
|
||||
onDraftChange(
|
||||
draft.serviceId != null &&
|
||||
(linkedService == null || !serviceSupportsWorkOrderType(linkedService, type))
|
||||
? { type, severity, serviceId: null, pm: "" }
|
||||
: { type, severity },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SlideOverScheduleOnField({
|
||||
draft,
|
||||
onDraftChange,
|
||||
|
|
@ -114,6 +208,11 @@ export function SlideOverInfoTabEditView({
|
|||
sites,
|
||||
vendors,
|
||||
pmTypes,
|
||||
services = [],
|
||||
serviceHistory,
|
||||
serviceQuery,
|
||||
serviceHistoryQuery,
|
||||
serviceHistoryReady = true,
|
||||
allOrders,
|
||||
onDraftChange,
|
||||
onDuplicateFound,
|
||||
|
|
@ -162,10 +261,13 @@ export function SlideOverInfoTabEditView({
|
|||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Type of WO">
|
||||
<TypeCell
|
||||
value={draft.type}
|
||||
severity={draft.severity}
|
||||
onSave={(type, severity) => onDraftChange({ type, severity })}
|
||||
<SlideOverTypeField
|
||||
draft={draft}
|
||||
services={services}
|
||||
serviceHistory={serviceHistory}
|
||||
serviceHistoryReady={serviceHistoryReady}
|
||||
serviceHistoryQuery={serviceHistoryQuery}
|
||||
onDraftChange={onDraftChange}
|
||||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Assigned To">
|
||||
|
|
@ -192,25 +294,13 @@ export function SlideOverInfoTabEditView({
|
|||
onSave={(apptTime) => onDraftChange({ apptTime })}
|
||||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Service">
|
||||
<PMTypeCell
|
||||
value={draft.pm}
|
||||
pmTypes={pmTypes}
|
||||
serviceNotes={draft.serviceNotes}
|
||||
extraServices={draft.extraServices}
|
||||
avetaRequired={draft.avetaRequired}
|
||||
woNumber={draft.woNumber}
|
||||
onSave={(pm) => onDraftChange({ pm })}
|
||||
onSaveDetails={(details) =>
|
||||
onDraftChange({
|
||||
pm: details.pm,
|
||||
serviceNotes: details.serviceNotes,
|
||||
extraServices: details.extraServices,
|
||||
avetaRequired: details.avetaRequired,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverServiceField
|
||||
draft={draft}
|
||||
pmTypes={pmTypes}
|
||||
services={services}
|
||||
serviceQuery={serviceQuery}
|
||||
onDraftChange={onDraftChange}
|
||||
/>
|
||||
<SlideOverInfoRow label="Vendor">
|
||||
<VendorCell
|
||||
vendorId={draft.vendorId}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import type {
|
|||
WorkOrderUser,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
import { SlideOverInfoTabDisplayView } from "./slide-over-info-tab-display-view";
|
||||
import { SlideOverInfoTabEditView } from "./slide-over-info-tab-edit-view";
|
||||
|
||||
|
|
@ -17,6 +19,11 @@ type SlideOverInfoTabProps = {
|
|||
sites: LocationOption[];
|
||||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
services?: Service[];
|
||||
serviceHistory?: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryReady?: boolean;
|
||||
allOrders: WorkOrderTableRow[];
|
||||
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
|
|
@ -33,6 +40,11 @@ export function SlideOverInfoTab({
|
|||
sites,
|
||||
vendors,
|
||||
pmTypes,
|
||||
services = [],
|
||||
serviceHistory,
|
||||
serviceQuery,
|
||||
serviceHistoryQuery,
|
||||
serviceHistoryReady = true,
|
||||
allOrders,
|
||||
onDraftChange,
|
||||
onDuplicateFound,
|
||||
|
|
@ -50,6 +62,11 @@ export function SlideOverInfoTab({
|
|||
sites={sites}
|
||||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
services={services}
|
||||
serviceHistory={serviceHistory}
|
||||
serviceQuery={serviceQuery}
|
||||
serviceHistoryQuery={serviceHistoryQuery}
|
||||
serviceHistoryReady={serviceHistoryReady}
|
||||
allOrders={allOrders}
|
||||
onDraftChange={onDraftChange}
|
||||
onDuplicateFound={onDuplicateFound}
|
||||
|
|
@ -67,6 +84,7 @@ export function SlideOverInfoTab({
|
|||
sites={sites}
|
||||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
services={services}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import type {
|
|||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { resolveSlideOverAvetaRequired } from "@/domain/work-orders/utils/resolve-slide-over-aveta-required";
|
||||
import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
import type { useWorkOrderSlideOver, SlideOverTab } from "./use-work-order-slide-over";
|
||||
import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel";
|
||||
|
||||
|
|
@ -25,6 +27,11 @@ type WorkOrderSlideOverTabsProps = {
|
|||
sites: LocationOption[];
|
||||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
services?: Service[];
|
||||
serviceHistory?: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryReady?: boolean;
|
||||
allOrders: WorkOrderTableRow[];
|
||||
commentHighlight?: CommentHighlight | null;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
|
|
@ -32,44 +39,24 @@ type WorkOrderSlideOverTabsProps = {
|
|||
onRequestComplete?: () => void;
|
||||
};
|
||||
|
||||
export function WorkOrderSlideOverTabs({
|
||||
type SecondarySlideOverTabProps = Pick<
|
||||
WorkOrderSlideOverTabsProps,
|
||||
"tab" | "state" | "users" | "commentHighlight" | "onOpenCompletionDoc"
|
||||
>;
|
||||
|
||||
function SecondarySlideOverTabs({
|
||||
tab,
|
||||
state,
|
||||
saving,
|
||||
users,
|
||||
sites,
|
||||
vendors,
|
||||
pmTypes,
|
||||
allOrders,
|
||||
commentHighlight,
|
||||
onDuplicateFound,
|
||||
onOpenCompletionDoc,
|
||||
onRequestComplete,
|
||||
}: WorkOrderSlideOverTabsProps) {
|
||||
}: SecondarySlideOverTabProps) {
|
||||
const hasError = Boolean(state.error) && !state.detailForRow;
|
||||
const showContent = Boolean(state.detailForRow || !state.isLoading);
|
||||
const { infoSource, activeDraft } = state;
|
||||
|
||||
return (
|
||||
<Box className="flex-1 overflow-y-auto p-5">
|
||||
{tab === "info" && infoSource != null && activeDraft != null && (
|
||||
<SlideOverInfoTab
|
||||
row={infoSource}
|
||||
draft={activeDraft}
|
||||
editing={state.editing && !saving}
|
||||
isCancelled={state.isCancelled}
|
||||
isCompleted={state.isCompleted}
|
||||
users={users}
|
||||
sites={sites}
|
||||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
allOrders={allOrders}
|
||||
onDraftChange={state.handleDraftChange}
|
||||
onDuplicateFound={onDuplicateFound}
|
||||
onRequestComplete={onRequestComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
<>
|
||||
{tab === "comments" && (
|
||||
<WorkOrderSlideOverTabPanel
|
||||
variant="comments"
|
||||
|
|
@ -111,7 +98,7 @@ export function WorkOrderSlideOverTabs({
|
|||
</WorkOrderSlideOverTabPanel>
|
||||
)}
|
||||
|
||||
{tab === "completion" && state.infoSource != null && (
|
||||
{tab === "completion" && infoSource != null && (
|
||||
<WorkOrderSlideOverTabPanel
|
||||
variant="completion"
|
||||
showSkeleton={state.showDetailSkeleton}
|
||||
|
|
@ -121,16 +108,14 @@ export function WorkOrderSlideOverTabs({
|
|||
showContent={showContent}
|
||||
>
|
||||
<SlideOverCompletionTab
|
||||
row={state.infoSource}
|
||||
row={infoSource}
|
||||
isReadOnly={state.isDocumentsReadOnly}
|
||||
completion={state.detailForRow?.completion}
|
||||
media={state.completionMedia}
|
||||
mediaCounts={state.mediaCounts}
|
||||
mediaPending={state.mediaPending}
|
||||
completionPending={state.completionPending}
|
||||
onUploadMedia={(requests) => {
|
||||
state.uploadMediaRequests(requests);
|
||||
}}
|
||||
onUploadMedia={(requests) => state.uploadMediaRequests(requests)}
|
||||
onCategorizeMedia={state.categorizeMedia}
|
||||
onRemoveMedia={state.removeMedia}
|
||||
onRetryMedia={state.retryUpload}
|
||||
|
|
@ -161,14 +146,69 @@ export function WorkOrderSlideOverTabs({
|
|||
)}
|
||||
isReadOnly={state.isExtrasReadOnly}
|
||||
pending={state.mediaPending}
|
||||
onUpload={(files) => {
|
||||
void state.uploadExtraFiles(files);
|
||||
}}
|
||||
onUpload={(files) => void state.uploadExtraFiles(files)}
|
||||
onRemove={state.removeMedia}
|
||||
onCategorize={state.categorizeMedia}
|
||||
/>
|
||||
</WorkOrderSlideOverTabPanel>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkOrderSlideOverTabs({
|
||||
tab,
|
||||
state,
|
||||
saving,
|
||||
users,
|
||||
sites,
|
||||
vendors,
|
||||
pmTypes,
|
||||
services = [],
|
||||
serviceHistory,
|
||||
serviceQuery,
|
||||
serviceHistoryQuery,
|
||||
serviceHistoryReady = true,
|
||||
allOrders,
|
||||
commentHighlight,
|
||||
onDuplicateFound,
|
||||
onOpenCompletionDoc,
|
||||
onRequestComplete,
|
||||
}: WorkOrderSlideOverTabsProps) {
|
||||
const { infoSource, activeDraft } = state;
|
||||
|
||||
return (
|
||||
<Box className="flex-1 overflow-y-auto p-5">
|
||||
{tab === "info" && infoSource != null && activeDraft != null && (
|
||||
<SlideOverInfoTab
|
||||
row={infoSource}
|
||||
draft={activeDraft}
|
||||
editing={state.editing && !saving}
|
||||
isCancelled={state.isCancelled}
|
||||
isCompleted={state.isCompleted}
|
||||
users={users}
|
||||
sites={sites}
|
||||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
services={services}
|
||||
serviceHistory={serviceHistory}
|
||||
serviceQuery={serviceQuery}
|
||||
serviceHistoryQuery={serviceHistoryQuery}
|
||||
serviceHistoryReady={serviceHistoryReady}
|
||||
allOrders={allOrders}
|
||||
onDraftChange={state.handleDraftChange}
|
||||
onDuplicateFound={onDuplicateFound}
|
||||
onRequestComplete={onRequestComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SecondarySlideOverTabs
|
||||
tab={tab}
|
||||
state={state}
|
||||
users={users}
|
||||
commentHighlight={commentHighlight}
|
||||
onOpenCompletionDoc={onOpenCompletionDoc}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import type {
|
|||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
import { useWorkOrderSlideOver, type SlideOverTab } from "./use-work-order-slide-over";
|
||||
import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav";
|
||||
import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
|
||||
|
|
@ -28,6 +30,11 @@ type WorkOrderSlideOverProps = {
|
|||
sites: LocationOption[];
|
||||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
services?: Service[];
|
||||
serviceHistory?: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryReady?: boolean;
|
||||
allOrders: WorkOrderTableRow[];
|
||||
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
|
||||
onOpenCompletionDoc: (row: WorkOrderTableRow) => void;
|
||||
|
|
@ -50,6 +57,11 @@ export function WorkOrderSlideOver({
|
|||
sites,
|
||||
vendors,
|
||||
pmTypes,
|
||||
services = [],
|
||||
serviceHistory,
|
||||
serviceQuery,
|
||||
serviceHistoryQuery,
|
||||
serviceHistoryReady = true,
|
||||
allOrders,
|
||||
onSave,
|
||||
onOpenCompletionDoc,
|
||||
|
|
@ -101,6 +113,11 @@ export function WorkOrderSlideOver({
|
|||
sites={sites}
|
||||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
services={services}
|
||||
serviceHistory={serviceHistory}
|
||||
serviceQuery={serviceQuery}
|
||||
serviceHistoryQuery={serviceHistoryQuery}
|
||||
serviceHistoryReady={serviceHistoryReady}
|
||||
allOrders={allOrders}
|
||||
commentHighlight={commentHighlight}
|
||||
onDuplicateFound={onDuplicateFound}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,211 @@ import {
|
|||
type WorkOrderAdvancedFilters,
|
||||
} from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { WIZARD_STATUS_DOTS } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
type FilterActions = Pick<AdvancedSearchFilterSectionsProps, "draft" | "toggleIn" | "setMany">;
|
||||
|
||||
function AdvSelectAllRow({
|
||||
setMany,
|
||||
keyName,
|
||||
values,
|
||||
selectedCount,
|
||||
}: {
|
||||
setMany: AdvancedSearchFilterSectionsProps["setMany"];
|
||||
keyName: AdvancedFilterSetKey;
|
||||
values: string[];
|
||||
selectedCount: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-2 flex justify-end">
|
||||
<SelectAllAction
|
||||
anySelected={selectedCount > 0}
|
||||
onClick={() => setMany(keyName, values, selectedCount === 0)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdvancedStatusSection({
|
||||
draft,
|
||||
setDraft,
|
||||
toggleIn,
|
||||
setMany,
|
||||
}: FilterActions & Pick<AdvancedSearchFilterSectionsProps, "setDraft">) {
|
||||
return (
|
||||
<AdvSection
|
||||
icon={CheckCircle2}
|
||||
label="Status"
|
||||
count={draft.statuses.size + (draft.pastDue ? 1 : 0)}
|
||||
>
|
||||
<AdvSelectAllRow
|
||||
setMany={setMany}
|
||||
keyName="statuses"
|
||||
values={[...ALL_WIZARD_STATUSES]}
|
||||
selectedCount={draft.statuses.size}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ALL_WIZARD_STATUSES.map((status) => (
|
||||
<AdvStatusPill
|
||||
key={status}
|
||||
label={status}
|
||||
dotColor={WIZARD_STATUS_DOTS[status]}
|
||||
selected={draft.statuses.has(status)}
|
||||
selectedBg="var(--color-primary-tint)"
|
||||
onClick={() => toggleIn("statuses", status)}
|
||||
/>
|
||||
))}
|
||||
<AdvStatusPill
|
||||
label="Past Due"
|
||||
dotColor="var(--status-pastdue-text)"
|
||||
selected={draft.pastDue}
|
||||
selectedBg="var(--status-pastdue-bg)"
|
||||
selectedText="var(--status-pastdue-text)"
|
||||
onClick={() => setDraft((prev) => ({ ...prev, pastDue: !prev.pastDue }))}
|
||||
/>
|
||||
</div>
|
||||
</AdvSection>
|
||||
);
|
||||
}
|
||||
|
||||
type AdvancedServiceSectionProps = FilterActions &
|
||||
Pick<AdvancedSearchFilterSectionsProps, "services" | "serviceQuery" | "pmTypes">;
|
||||
|
||||
function buildServiceFilterOptions(
|
||||
draft: WorkOrderAdvancedFilters,
|
||||
services: Service[] | undefined,
|
||||
pmTypes: string[] | undefined,
|
||||
) {
|
||||
const serviceRegistryManaged = services !== undefined;
|
||||
const serviceList = services ?? [];
|
||||
const options = serviceRegistryManaged
|
||||
? [
|
||||
...serviceList.map((service) => ({ value: service.name, label: service.name })),
|
||||
...[...draft.pmTypes]
|
||||
.filter((name) => !serviceList.some((service) => service.name === name))
|
||||
.map((name) => ({ value: name, label: name })),
|
||||
]
|
||||
: (pmTypes ?? []).map((name) => ({ value: name, label: name }));
|
||||
return { serviceRegistryManaged, serviceList, options };
|
||||
}
|
||||
|
||||
function AdvancedServiceRegistryFeedback({
|
||||
query,
|
||||
managed,
|
||||
services,
|
||||
}: {
|
||||
query?: ServiceRegistryQueryState;
|
||||
managed: boolean;
|
||||
services: Service[];
|
||||
}) {
|
||||
const showEmpty =
|
||||
managed &&
|
||||
query?.hasData === true &&
|
||||
!query.isLoading &&
|
||||
!query.isError &&
|
||||
services.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{Boolean(query?.isLoading && !query.hasData) && (
|
||||
<div role="status" aria-label="Loading services">
|
||||
<Text as="span" variant="description">
|
||||
Loading services…
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(query?.isFetching && query.hasData && !query.isError) && (
|
||||
<div role="status" aria-label="Updating services">
|
||||
<Text as="span" variant="description">
|
||||
Updating services…
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(query?.isError) && (
|
||||
<div role="alert" aria-label="Service filters unavailable">
|
||||
<QueryErrorPanel
|
||||
compact
|
||||
message="Service filters couldn't load. Retry to load registry services."
|
||||
onRetry={query?.retry}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showEmpty && (
|
||||
<Text as="div" variant="description">
|
||||
No services are available.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AdvancedServiceSection({
|
||||
draft,
|
||||
toggleIn,
|
||||
setMany,
|
||||
services,
|
||||
serviceQuery,
|
||||
pmTypes,
|
||||
}: AdvancedServiceSectionProps) {
|
||||
const { serviceRegistryManaged, serviceList, options } = buildServiceFilterOptions(
|
||||
draft,
|
||||
services,
|
||||
pmTypes,
|
||||
);
|
||||
const unavailable =
|
||||
serviceRegistryManaged &&
|
||||
(serviceQuery?.isLoading === true ||
|
||||
(serviceQuery?.isError === true && serviceQuery.hasData === false));
|
||||
|
||||
return (
|
||||
<AdvSection icon={Flame} label="Service" count={draft.pmTypes.size}>
|
||||
<AdvancedServiceRegistryFeedback
|
||||
query={serviceQuery}
|
||||
managed={serviceRegistryManaged}
|
||||
services={serviceList}
|
||||
/>
|
||||
<AdvMultiSelect
|
||||
options={options}
|
||||
selected={draft.pmTypes}
|
||||
onToggle={(value) => toggleIn("pmTypes", value)}
|
||||
onBulkToggle={(values, select) => setMany("pmTypes", values, select)}
|
||||
placeholder="Search service…"
|
||||
disabled={unavailable}
|
||||
renderLeft={(name) => (
|
||||
<PmTypeIcon pmType={name} size={14} className="shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
/>
|
||||
</AdvSection>
|
||||
);
|
||||
}
|
||||
|
||||
function AdvancedCompletionDocSection({ draft, toggleIn, setMany }: FilterActions) {
|
||||
return (
|
||||
<AdvSection icon={FileText} label="Completion doc" count={draft.docs.size}>
|
||||
<AdvSelectAllRow
|
||||
setMany={setMany}
|
||||
keyName="docs"
|
||||
values={[...COMPLETION_DOC_OPTIONS]}
|
||||
selectedCount={draft.docs.size}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{COMPLETION_DOC_OPTIONS.map((doc) => (
|
||||
<AdvDocPill
|
||||
key={doc}
|
||||
label={DOC_FILTER_LABELS[doc]}
|
||||
selected={draft.docs.has(doc)}
|
||||
onClick={() => toggleIn("docs", doc)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AdvSection>
|
||||
);
|
||||
}
|
||||
|
||||
type AdvancedSearchFilterSectionsProps = {
|
||||
draft: WorkOrderAdvancedFilters;
|
||||
setDraft: Dispatch<SetStateAction<WorkOrderAdvancedFilters>>;
|
||||
|
|
@ -37,7 +240,9 @@ type AdvancedSearchFilterSectionsProps = {
|
|||
setMany: (key: AdvancedFilterSetKey, values: string[], select: boolean) => void;
|
||||
sites: { id: string | number; name: string }[];
|
||||
users: WorkOrderUser[];
|
||||
pmTypes: string[];
|
||||
services?: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
pmTypes?: string[];
|
||||
vendorTechs: string[];
|
||||
};
|
||||
|
||||
|
|
@ -48,18 +253,11 @@ export function AdvancedSearchFilterSections({
|
|||
setMany,
|
||||
sites,
|
||||
users,
|
||||
pmTypes,
|
||||
services,
|
||||
serviceQuery,
|
||||
pmTypes = [],
|
||||
vendorTechs,
|
||||
}: AdvancedSearchFilterSectionsProps) {
|
||||
const selectAllRow = (key: AdvancedFilterSetKey, all: string[], selectedSize: number) => (
|
||||
<div className="mb-2 flex justify-end">
|
||||
<SelectAllAction
|
||||
anySelected={selectedSize > 0}
|
||||
onClick={() => setMany(key, all, selectedSize === 0)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4">
|
||||
<AdvDateRangeSection draft={draft} setDraft={setDraft} />
|
||||
|
|
@ -109,48 +307,23 @@ export function AdvancedSearchFilterSections({
|
|||
/>
|
||||
</AdvSection>
|
||||
|
||||
<AdvSection
|
||||
icon={CheckCircle2}
|
||||
label="Status"
|
||||
count={draft.statuses.size + (draft.pastDue ? 1 : 0)}
|
||||
>
|
||||
{selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ALL_WIZARD_STATUSES.map((status) => (
|
||||
<AdvStatusPill
|
||||
key={status}
|
||||
label={status}
|
||||
dotColor={WIZARD_STATUS_DOTS[status]}
|
||||
selected={draft.statuses.has(status)}
|
||||
selectedBg="var(--color-primary-tint)"
|
||||
onClick={() => toggleIn("statuses", status)}
|
||||
/>
|
||||
))}
|
||||
<AdvStatusPill
|
||||
label="Past Due"
|
||||
dotColor="var(--status-pastdue-text)"
|
||||
selected={draft.pastDue}
|
||||
selectedBg="var(--status-pastdue-bg)"
|
||||
selectedText="var(--status-pastdue-text)"
|
||||
onClick={() => setDraft((prev) => ({ ...prev, pastDue: !prev.pastDue }))}
|
||||
/>
|
||||
</div>
|
||||
</AdvSection>
|
||||
<AdvancedStatusSection
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
toggleIn={toggleIn}
|
||||
setMany={setMany}
|
||||
/>
|
||||
|
||||
<AdvIndicatorsSection draft={draft} setDraft={setDraft} />
|
||||
|
||||
<AdvSection icon={Flame} label="Service" count={draft.pmTypes.size}>
|
||||
<AdvMultiSelect
|
||||
options={pmTypes.map((pmType) => ({ value: pmType, label: pmType }))}
|
||||
selected={draft.pmTypes}
|
||||
onToggle={(value) => toggleIn("pmTypes", value)}
|
||||
onBulkToggle={(values, select) => setMany("pmTypes", values, select)}
|
||||
placeholder="Search service…"
|
||||
renderLeft={(pmType) => (
|
||||
<PmTypeIcon pmType={pmType} size={14} className="shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
/>
|
||||
</AdvSection>
|
||||
<AdvancedServiceSection
|
||||
draft={draft}
|
||||
toggleIn={toggleIn}
|
||||
setMany={setMany}
|
||||
services={services}
|
||||
serviceQuery={serviceQuery}
|
||||
pmTypes={pmTypes}
|
||||
/>
|
||||
|
||||
<AdvSection icon={Truck} label="Vendor / tech" count={draft.vendorTechs.size}>
|
||||
<AdvMultiSelect
|
||||
|
|
@ -162,19 +335,7 @@ export function AdvancedSearchFilterSections({
|
|||
/>
|
||||
</AdvSection>
|
||||
|
||||
<AdvSection icon={FileText} label="Completion doc" count={draft.docs.size}>
|
||||
{selectAllRow("docs", [...COMPLETION_DOC_OPTIONS], draft.docs.size)}
|
||||
<div className="flex items-center gap-2">
|
||||
{COMPLETION_DOC_OPTIONS.map((doc) => (
|
||||
<AdvDocPill
|
||||
key={doc}
|
||||
label={DOC_FILTER_LABELS[doc]}
|
||||
selected={draft.docs.has(doc)}
|
||||
onClick={() => toggleIn("docs", doc)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AdvSection>
|
||||
<AdvancedCompletionDocSection draft={draft} toggleIn={toggleIn} setMany={setMany} />
|
||||
|
||||
<AdvFlagInternalUpliftSections
|
||||
draft={draft}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ type AdvMultiSelectProps = {
|
|||
placeholder: string;
|
||||
|
||||
renderLeft?: (value: string) => ReactNode;
|
||||
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function AdvMultiSelect({
|
||||
|
|
@ -38,6 +40,7 @@ export function AdvMultiSelect({
|
|||
placeholder,
|
||||
|
||||
renderLeft,
|
||||
disabled = false,
|
||||
}: AdvMultiSelectProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
|
|
@ -55,6 +58,7 @@ export function AdvMultiSelect({
|
|||
placeholder={placeholder}
|
||||
fullWidth
|
||||
className="mb-2"
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
|
|
@ -64,6 +68,7 @@ export function AdvMultiSelect({
|
|||
|
||||
<SelectAllAction
|
||||
anySelected={anySelected}
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
anySelected
|
||||
? onBulkToggle?.(
|
||||
|
|
@ -87,6 +92,7 @@ export function AdvMultiSelect({
|
|||
selected={selected.has(option.value)}
|
||||
label={option.label}
|
||||
left={renderLeft?.(option.value)}
|
||||
disabled={disabled}
|
||||
onClick={() => onToggle(option.value)}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { IconCloseButton } from "@/components/ui/icon-close-button";
|
|||
import { SideSheet } from "@/components/ui/side-sheet";
|
||||
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
|
||||
type WorkOrdersAdvancedSearchSheetProps = {
|
||||
open: boolean;
|
||||
|
|
@ -15,7 +17,8 @@ type WorkOrdersAdvancedSearchSheetProps = {
|
|||
onClearAll: () => void;
|
||||
sites: { id: string | number; name: string }[];
|
||||
users: WorkOrderUser[];
|
||||
pmTypes: string[];
|
||||
services?: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
vendorTechs: string[];
|
||||
};
|
||||
|
||||
|
|
@ -27,7 +30,8 @@ export function WorkOrdersAdvancedSearchSheet({
|
|||
onClearAll,
|
||||
sites,
|
||||
users,
|
||||
pmTypes,
|
||||
services,
|
||||
serviceQuery,
|
||||
vendorTechs,
|
||||
}: WorkOrdersAdvancedSearchSheetProps) {
|
||||
const { draft, setDraft, activeCount, toggleIn, setMany, handleClearAll, handleApply } =
|
||||
|
|
@ -59,7 +63,8 @@ export function WorkOrdersAdvancedSearchSheet({
|
|||
setMany={setMany}
|
||||
sites={sites}
|
||||
users={users}
|
||||
pmTypes={pmTypes}
|
||||
services={services}
|
||||
serviceQuery={serviceQuery}
|
||||
vendorTechs={vendorTechs}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,22 +4,41 @@ import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order
|
|||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import { resolveCreationStatus } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
|
||||
function resolveInlineDraftLocation(draft: WorkOrderTableDraft, sites: LocationOption[]) {
|
||||
const location = sites.find((site) => String(site.id) === String(draft.locationId));
|
||||
const locationName = draft.location || draft.site || location?.name || "";
|
||||
return {
|
||||
locationName,
|
||||
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveInlineDraftSchedule(draft: WorkOrderTableDraft) {
|
||||
const hasWeekTarget = Boolean(draft.targetWeek?.trim());
|
||||
const scheduleWeekOnly = draft.scheduleWeekOnly && (hasWeekTarget || Boolean(draft.scheduledOn));
|
||||
return {
|
||||
scheduleWeekOnly,
|
||||
status: resolveCreationStatus({
|
||||
status: draft.status || "Incomplete",
|
||||
scheduledOn: draft.scheduledOn,
|
||||
scheduleWeekOnly,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
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 || "";
|
||||
|
||||
const hasWeekTarget = Boolean(draft.targetWeek?.trim());
|
||||
const scheduleWeekOnly = draft.scheduleWeekOnly && (hasWeekTarget || Boolean(draft.scheduledOn));
|
||||
const location = resolveInlineDraftLocation(draft, sites);
|
||||
const schedule = resolveInlineDraftSchedule(draft);
|
||||
|
||||
return {
|
||||
locationId: String(draft.locationId || ""),
|
||||
locationName,
|
||||
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
|
||||
locationName: location.locationName,
|
||||
siteCode: location.siteCode,
|
||||
woNumber: draft.woNumber,
|
||||
type: draft.type || "PM",
|
||||
severity: draft.severity,
|
||||
|
|
@ -28,7 +47,7 @@ export function buildInlineDraftCreateInput(
|
|||
scheduledOn: draft.scheduledOn,
|
||||
dueDate: draft.dueDate,
|
||||
pm: draft.pm,
|
||||
serviceId: null,
|
||||
serviceId: draft.serviceId ?? null,
|
||||
extraServices: draft.extraServices ?? [],
|
||||
avetaRequired: Boolean(draft.avetaRequired),
|
||||
pmNote: draft.serviceNotes ?? "",
|
||||
|
|
@ -39,16 +58,12 @@ export function buildInlineDraftCreateInput(
|
|||
vendorNotes: "",
|
||||
apptStart: draft.apptStart,
|
||||
apptEnd: draft.apptEnd,
|
||||
status: resolveCreationStatus({
|
||||
status: draft.status || "Incomplete",
|
||||
scheduledOn: draft.scheduledOn,
|
||||
scheduleWeekOnly,
|
||||
}),
|
||||
status: schedule.status,
|
||||
pocName: draft.pocName,
|
||||
pocPhone: draft.pocPhone,
|
||||
pocNotes: draft.pocNotes,
|
||||
additionalContacts: draft.additionalContacts ?? [],
|
||||
scheduleWeekOnly,
|
||||
scheduleWeekOnly: schedule.scheduleWeekOnly,
|
||||
targetWeek: draft.targetWeek || "",
|
||||
scheduleEnd: draft.scheduleEnd || "",
|
||||
schedMode: draft.schedMode,
|
||||
|
|
|
|||
|
|
@ -13,12 +13,16 @@ import {
|
|||
hasServiceNotesDetails,
|
||||
truncateServiceNotesPreview,
|
||||
} from "@/domain/work-orders/utils/service-notes-ui";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
import { servicesForWorkOrderType } from "@/domain/services/utils/services-for-work-order-type";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
type PMTypeCellProps = {
|
||||
value: string;
|
||||
pmTypes: string[];
|
||||
q?: string;
|
||||
onSave: (v: string) => void;
|
||||
onSave: (v: string, serviceId?: Service["id"]) => void;
|
||||
serviceNotes?: string;
|
||||
extraServices?: string[];
|
||||
avetaRequired?: boolean;
|
||||
|
|
@ -26,155 +30,284 @@ type PMTypeCellProps = {
|
|||
woNumber?: string;
|
||||
onSaveDetails?: (details: ServiceNotesDetails) => void;
|
||||
viewOnly?: boolean;
|
||||
services?: Service[];
|
||||
serviceId?: string | number | null;
|
||||
workOrderType?: WOType | "";
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
};
|
||||
|
||||
export function PMTypeCell({
|
||||
value,
|
||||
pmTypes,
|
||||
q,
|
||||
onSave,
|
||||
serviceNotes = "",
|
||||
extraServices = [],
|
||||
avetaRequired = false,
|
||||
avetaAttached = false,
|
||||
woNumber,
|
||||
onSaveDetails,
|
||||
viewOnly = false,
|
||||
}: PMTypeCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
type PMTypeCellModel = {
|
||||
assignableServices?: Service[];
|
||||
displayValue: string;
|
||||
filtered: string[];
|
||||
registryManaged: boolean;
|
||||
choicesUnavailable: boolean;
|
||||
catalogEmpty: boolean;
|
||||
hasDetails: boolean;
|
||||
notePreview: string;
|
||||
};
|
||||
|
||||
const filtered = search
|
||||
? pmTypes.filter((p) => p.toLowerCase().includes(search.toLowerCase()))
|
||||
: pmTypes;
|
||||
function getAssignableServices(props: PMTypeCellProps) {
|
||||
return props.services == null
|
||||
? undefined
|
||||
: servicesForWorkOrderType(props.services, props.workOrderType ?? "");
|
||||
}
|
||||
|
||||
const extras = extraServices ?? [];
|
||||
const hasDetails = hasServiceNotesDetails(serviceNotes, extras, avetaRequired);
|
||||
const notePreview = serviceNotes?.trim() ? truncateServiceNotesPreview(serviceNotes) : "";
|
||||
function getCellDisplayValue(props: PMTypeCellProps) {
|
||||
const linkedService = props.services?.find(
|
||||
(service) => String(service.id) === String(props.serviceId),
|
||||
);
|
||||
return !props.viewOnly && linkedService?.isActive ? linkedService.name : props.value;
|
||||
}
|
||||
|
||||
const closePopover = () => {
|
||||
setAnchorEl(null);
|
||||
setSearch("");
|
||||
function getServiceChoicesUnavailable(props: PMTypeCellProps, registryManaged: boolean) {
|
||||
const query = props.serviceQuery;
|
||||
return (
|
||||
registryManaged &&
|
||||
(query?.isLoading === true || (query?.isError === true && query.hasData === false))
|
||||
);
|
||||
}
|
||||
|
||||
function isServiceCatalogEmpty(
|
||||
props: PMTypeCellProps,
|
||||
registryManaged: boolean,
|
||||
services: Service[] | undefined,
|
||||
) {
|
||||
const query = props.serviceQuery;
|
||||
return (
|
||||
registryManaged &&
|
||||
(query?.hasData ?? true) &&
|
||||
!query?.isLoading &&
|
||||
!query?.isError &&
|
||||
(services?.length ?? props.pmTypes.length) === 0
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePMTypeCellModel(props: PMTypeCellProps, search: string): PMTypeCellModel {
|
||||
const assignableServices = getAssignableServices(props);
|
||||
const serviceNames = assignableServices?.map((service) => service.name) ?? props.pmTypes;
|
||||
const registryManaged = props.services !== undefined;
|
||||
|
||||
return {
|
||||
assignableServices,
|
||||
displayValue: getCellDisplayValue(props),
|
||||
filtered: search
|
||||
? serviceNames.filter((name) => name.toLowerCase().includes(search.toLowerCase()))
|
||||
: serviceNames,
|
||||
registryManaged,
|
||||
choicesUnavailable: getServiceChoicesUnavailable(props, registryManaged),
|
||||
catalogEmpty: isServiceCatalogEmpty(props, registryManaged, assignableServices),
|
||||
hasDetails: hasServiceNotesDetails(
|
||||
props.serviceNotes ?? "",
|
||||
props.extraServices ?? [],
|
||||
props.avetaRequired ?? false,
|
||||
),
|
||||
notePreview: props.serviceNotes?.trim() ? truncateServiceNotesPreview(props.serviceNotes) : "",
|
||||
};
|
||||
}
|
||||
|
||||
const modal =
|
||||
onSaveDetails != null || viewOnly ? (
|
||||
<ServiceNotesDialog
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
pm={value}
|
||||
extraServices={extras}
|
||||
serviceNotes={serviceNotes ?? ""}
|
||||
avetaRequired={avetaRequired}
|
||||
woNumber={woNumber}
|
||||
pmTypes={pmTypes}
|
||||
viewOnly={viewOnly}
|
||||
onSave={onSaveDetails ?? (() => undefined)}
|
||||
function PMTypeCellDialog({
|
||||
props,
|
||||
model,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
props: PMTypeCellProps;
|
||||
model: PMTypeCellModel;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
if (!props.viewOnly && props.onSaveDetails == null) return null;
|
||||
|
||||
return (
|
||||
<ServiceNotesDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
pm={model.displayValue}
|
||||
extraServices={props.extraServices ?? []}
|
||||
serviceNotes={props.serviceNotes ?? ""}
|
||||
avetaRequired={props.avetaRequired ?? false}
|
||||
woNumber={props.woNumber}
|
||||
pmTypes={props.pmTypes}
|
||||
services={model.assignableServices}
|
||||
serviceId={props.serviceId}
|
||||
serviceQuery={props.serviceQuery}
|
||||
viewOnly={props.viewOnly ?? false}
|
||||
onSave={props.onSaveDetails ?? (() => undefined)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PMTypeCellReadOnly({
|
||||
props,
|
||||
model,
|
||||
onOpenDetails,
|
||||
}: {
|
||||
props: PMTypeCellProps;
|
||||
model: PMTypeCellModel;
|
||||
onOpenDetails: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenDetails();
|
||||
}}
|
||||
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
|
||||
>
|
||||
<PmTypeIcon
|
||||
pmType={model.displayValue}
|
||||
size={12}
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (viewOnly) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
|
||||
{model.displayValue ? (
|
||||
<span
|
||||
className="min-w-0 truncate"
|
||||
style={{ fontSize: 13, color: "var(--color-text-primary)" }}
|
||||
>
|
||||
<PmTypeIcon
|
||||
pmType={value}
|
||||
size={12}
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
{value ? (
|
||||
<span
|
||||
className="min-w-0 truncate"
|
||||
style={{ fontSize: 13, color: "var(--color-text-primary)" }}
|
||||
>
|
||||
<HighlightText text={value} q={q} />
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>—</span>
|
||||
)}
|
||||
{Boolean(avetaRequired) && <AvetaBadge size={11} attached={avetaAttached} />}
|
||||
</button>
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
<HighlightText text={model.displayValue} q={props.q} />
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>—</span>
|
||||
)}
|
||||
{Boolean(props.avetaRequired) && <AvetaBadge size={11} attached={props.avetaAttached} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasDetails && onSaveDetails) {
|
||||
return (
|
||||
<>
|
||||
<PmTypeDetailsTrigger
|
||||
value={value}
|
||||
q={q}
|
||||
notePreview={notePreview}
|
||||
extrasCount={extras.length}
|
||||
avetaRequired={avetaRequired}
|
||||
avetaAttached={avetaAttached}
|
||||
onOpenDetails={() => setModalOpen(true)}
|
||||
/>
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
function PMTypeCellDetails({
|
||||
props,
|
||||
model,
|
||||
onOpenDetails,
|
||||
}: {
|
||||
props: PMTypeCellProps;
|
||||
model: PMTypeCellModel;
|
||||
onOpenDetails: () => void;
|
||||
}) {
|
||||
return (
|
||||
<PmTypeDetailsTrigger
|
||||
value={model.displayValue}
|
||||
q={props.q}
|
||||
notePreview={model.notePreview}
|
||||
extrasCount={(props.extraServices ?? []).length}
|
||||
avetaRequired={props.avetaRequired ?? false}
|
||||
avetaAttached={props.avetaAttached ?? false}
|
||||
onOpenDetails={onOpenDetails}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PMTypeCellPicker({
|
||||
props,
|
||||
model,
|
||||
anchorEl,
|
||||
search,
|
||||
onOpenPicker,
|
||||
onSearchChange,
|
||||
onClose,
|
||||
onOpenDetails,
|
||||
}: {
|
||||
props: PMTypeCellProps;
|
||||
model: PMTypeCellModel;
|
||||
anchorEl: HTMLElement | null;
|
||||
search: string;
|
||||
onOpenPicker: (anchor: HTMLElement) => void;
|
||||
onSearchChange: (search: string) => void;
|
||||
onClose: () => void;
|
||||
onOpenDetails: () => void;
|
||||
}) {
|
||||
const onPick = (name: string) => {
|
||||
const service = model.assignableServices?.find((candidate) => candidate.name === name);
|
||||
props.onSave(name, service?.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAnchorEl(e.currentTarget);
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenPicker(event.currentTarget);
|
||||
}}
|
||||
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
|
||||
>
|
||||
<PmTypeIcon
|
||||
pmType={value}
|
||||
pmType={model.displayValue}
|
||||
size={12}
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
{value ? (
|
||||
{model.displayValue ? (
|
||||
<span
|
||||
className="min-w-0 truncate"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--color-text-primary)",
|
||||
}}
|
||||
style={{ fontSize: 13, color: "var(--color-text-primary)" }}
|
||||
>
|
||||
<HighlightText text={value} q={q} />
|
||||
<HighlightText text={model.displayValue} q={props.q} />
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>Select …</span>
|
||||
)}
|
||||
<HoverChevron />
|
||||
</button>
|
||||
|
||||
<PmTypePickerPopover
|
||||
anchorEl={anchorEl}
|
||||
search={search}
|
||||
filtered={filtered}
|
||||
value={value}
|
||||
showAddDetails={Boolean(onSaveDetails)}
|
||||
onSearchChange={setSearch}
|
||||
onClose={closePopover}
|
||||
onPick={(pm) => {
|
||||
onSave(pm);
|
||||
closePopover();
|
||||
}}
|
||||
filtered={model.filtered}
|
||||
value={model.displayValue}
|
||||
showAddDetails={Boolean(props.onSaveDetails)}
|
||||
onSearchChange={onSearchChange}
|
||||
onClose={onClose}
|
||||
registryManaged={model.registryManaged}
|
||||
loading={props.serviceQuery?.isLoading === true && !props.serviceQuery.hasData}
|
||||
fetching={props.serviceQuery?.isFetching === true && props.serviceQuery.hasData}
|
||||
error={props.serviceQuery?.isError === true}
|
||||
empty={model.catalogEmpty}
|
||||
selectionDisabled={model.choicesUnavailable}
|
||||
onRetry={props.serviceQuery?.retry}
|
||||
onPick={onPick}
|
||||
onAddDetails={() => {
|
||||
closePopover();
|
||||
setModalOpen(true);
|
||||
onClose();
|
||||
onOpenDetails();
|
||||
}}
|
||||
/>
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PMTypeCell(props: PMTypeCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const model = resolvePMTypeCellModel(props, search);
|
||||
const closePicker = () => {
|
||||
setAnchorEl(null);
|
||||
setSearch("");
|
||||
};
|
||||
const openDetails = () => setModalOpen(true);
|
||||
const content = props.viewOnly ? (
|
||||
<PMTypeCellReadOnly props={props} model={model} onOpenDetails={openDetails} />
|
||||
) : model.hasDetails && props.onSaveDetails ? (
|
||||
<PMTypeCellDetails props={props} model={model} onOpenDetails={openDetails} />
|
||||
) : (
|
||||
<PMTypeCellPicker
|
||||
props={props}
|
||||
model={model}
|
||||
anchorEl={anchorEl}
|
||||
search={search}
|
||||
onOpenPicker={setAnchorEl}
|
||||
onSearchChange={setSearch}
|
||||
onClose={closePicker}
|
||||
onOpenDetails={openDetails}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{content}
|
||||
<PMTypeCellDialog props={props} model={model} open={modalOpen} onOpenChange={setModalOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Check, Plus } from "lucide-react";
|
||||
import { Popover } from "@mui/material";
|
||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||
|
||||
type PmTypePickerPopoverProps = {
|
||||
|
|
@ -12,6 +13,13 @@ type PmTypePickerPopoverProps = {
|
|||
onClose: () => void;
|
||||
onPick: (pm: string) => void;
|
||||
onAddDetails: () => void;
|
||||
registryManaged?: boolean;
|
||||
loading?: boolean;
|
||||
fetching?: boolean;
|
||||
error?: boolean;
|
||||
empty?: boolean;
|
||||
selectionDisabled?: boolean;
|
||||
onRetry?: () => void;
|
||||
};
|
||||
|
||||
export function PmTypePickerPopover({
|
||||
|
|
@ -24,6 +32,13 @@ export function PmTypePickerPopover({
|
|||
onClose,
|
||||
onPick,
|
||||
onAddDetails,
|
||||
registryManaged = false,
|
||||
loading = false,
|
||||
fetching = false,
|
||||
error = false,
|
||||
empty = false,
|
||||
selectionDisabled = false,
|
||||
onRetry,
|
||||
}: PmTypePickerPopoverProps) {
|
||||
return (
|
||||
<Popover
|
||||
|
|
@ -40,11 +55,44 @@ export function PmTypePickerPopover({
|
|||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search service…"
|
||||
disabled={selectionDisabled}
|
||||
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">
|
||||
{loading && (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Loading services"
|
||||
className="px-2 py-3 text-xs text-muted-foreground"
|
||||
>
|
||||
Loading services…
|
||||
</div>
|
||||
)}
|
||||
{Boolean(fetching && !error) && (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Updating services"
|
||||
className="px-2 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
Updating services…
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div role="alert" aria-label="Service options unavailable">
|
||||
<QueryErrorPanel
|
||||
compact
|
||||
message="Service options couldn't load. Retry to load registry services."
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(registryManaged && empty) && (
|
||||
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||
No services are available for this work-order type.
|
||||
</div>
|
||||
)}
|
||||
{showAddDetails && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -66,6 +114,7 @@ export function PmTypePickerPopover({
|
|||
key={pm}
|
||||
type="button"
|
||||
onClick={() => onPick(pm)}
|
||||
disabled={selectionDisabled}
|
||||
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)" }} />
|
||||
|
|
@ -78,6 +127,9 @@ export function PmTypePickerPopover({
|
|||
{value === pm && <Check size={10} className="text-[var(--color-primary)]" />}
|
||||
</button>
|
||||
))}
|
||||
{Boolean(!loading && !error && !empty && filtered.length === 0) && (
|
||||
<div className="px-2 py-3 text-center text-xs text-muted-foreground">No matches</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,90 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState, type SetStateAction } 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";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
import { ServiceRegistryStatus } from "./service-registry-status";
|
||||
|
||||
export type ServiceNotesDetails = {
|
||||
pm: string;
|
||||
extraServices: string[];
|
||||
serviceNotes: string;
|
||||
avetaRequired: boolean;
|
||||
serviceId?: Service["id"] | null;
|
||||
};
|
||||
|
||||
type ServiceNotesSelectionFieldsProps = {
|
||||
pm: string;
|
||||
extraServices: string[];
|
||||
pmTypes: string[];
|
||||
services?: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
serviceOptionsUnavailable: boolean;
|
||||
serviceOptionsEmpty: boolean;
|
||||
sel: string[];
|
||||
onToggle: (value: string, optionId?: string | number) => void;
|
||||
note: string;
|
||||
setNote: (value: string) => void;
|
||||
search: string;
|
||||
setSearch: (value: string) => void;
|
||||
aveta: boolean;
|
||||
setAveta: (value: boolean) => void;
|
||||
viewOnly: boolean;
|
||||
};
|
||||
|
||||
function ServiceNotesSelectionFields({
|
||||
pm,
|
||||
extraServices,
|
||||
pmTypes,
|
||||
services,
|
||||
serviceQuery,
|
||||
serviceOptionsUnavailable,
|
||||
serviceOptionsEmpty,
|
||||
sel,
|
||||
onToggle,
|
||||
note,
|
||||
setNote,
|
||||
search,
|
||||
setSearch,
|
||||
aveta,
|
||||
setAveta,
|
||||
viewOnly,
|
||||
}: ServiceNotesSelectionFieldsProps) {
|
||||
const serviceOptions = services
|
||||
? [
|
||||
...services.map((service) => ({ id: service.id, value: service.name })),
|
||||
...[pm, ...extraServices]
|
||||
.filter((value) => value && !services.some((service) => service.name === value))
|
||||
.map((value) => ({ id: `historical:${value}`, value })),
|
||||
]
|
||||
: pmTypes.map((value) => ({ id: value, value }));
|
||||
|
||||
return (
|
||||
<fieldset disabled={viewOnly} className="min-w-0 space-y-4 border-0 p-0">
|
||||
<ServiceRegistryStatus
|
||||
query={serviceQuery}
|
||||
empty={serviceOptionsEmpty}
|
||||
emptyMessage="No services are available for this work-order type."
|
||||
/>
|
||||
<WizardServiceNotesFields
|
||||
services={serviceOptions}
|
||||
sel={sel}
|
||||
onToggle={onToggle}
|
||||
note={note}
|
||||
setNote={setNote}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
serviceSelectionDisabled={serviceOptionsUnavailable}
|
||||
avetaRequired={aveta}
|
||||
onAvetaRequiredChange={setAveta}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
type ServiceNotesDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
|
|
@ -21,10 +94,261 @@ type ServiceNotesDialogProps = {
|
|||
avetaRequired?: boolean;
|
||||
woNumber?: string;
|
||||
pmTypes: string[];
|
||||
services?: Service[];
|
||||
serviceId?: string | number | null;
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
viewOnly?: boolean;
|
||||
onSave: (details: ServiceNotesDetails) => void;
|
||||
};
|
||||
|
||||
type DialogTarget = {
|
||||
key: string;
|
||||
pm: string;
|
||||
serviceId?: string | number | null;
|
||||
};
|
||||
|
||||
function getDialogTarget(
|
||||
woNumber: string | undefined,
|
||||
serviceId: string | number | null | undefined,
|
||||
pm: string,
|
||||
) {
|
||||
const key = woNumber ? `wo:${woNumber}` : serviceId != null ? `service:${serviceId}` : `pm:${pm}`;
|
||||
return { key, pm, serviceId };
|
||||
}
|
||||
|
||||
function selectedServices(pm: string, extraServices: string[]) {
|
||||
return [pm, ...extraServices].filter(
|
||||
(value, index, values) => !!value && values.indexOf(value) === index,
|
||||
);
|
||||
}
|
||||
|
||||
function createServiceSelectionToggle({
|
||||
services,
|
||||
selected,
|
||||
primaryServiceId,
|
||||
serviceIdsByName,
|
||||
setSelected,
|
||||
setPrimaryServiceId,
|
||||
setServiceIdsByName,
|
||||
}: {
|
||||
services?: Service[];
|
||||
selected: string[];
|
||||
primaryServiceId?: string | number | null;
|
||||
serviceIdsByName: Record<string, string | number>;
|
||||
setSelected: (value: string[]) => void;
|
||||
setPrimaryServiceId: (value: string | number | null | undefined) => void;
|
||||
setServiceIdsByName: (value: SetStateAction<Record<string, string | number>>) => void;
|
||||
}) {
|
||||
return (value: string, optionId?: string | number) => {
|
||||
const option = services?.find((service) => String(service.id) === String(optionId));
|
||||
const optionServiceId = option?.id;
|
||||
const replacesSameLabel =
|
||||
selected[0] === value &&
|
||||
optionServiceId != null &&
|
||||
String(optionServiceId) !== String(primaryServiceId);
|
||||
if (replacesSameLabel) {
|
||||
setPrimaryServiceId(optionServiceId);
|
||||
setServiceIdsByName((current) => ({ ...current, [value]: optionServiceId }));
|
||||
return;
|
||||
}
|
||||
if (selected.includes(value)) {
|
||||
const next = selected.filter((item) => item !== value);
|
||||
setSelected(next);
|
||||
if (selected[0] === value) {
|
||||
const nextName = next[0];
|
||||
const nextService = services?.find((service) => service.name === nextName);
|
||||
setPrimaryServiceId(
|
||||
nextName == null ? undefined : (serviceIdsByName[nextName] ?? nextService?.id),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSelected([...selected, value]);
|
||||
if (optionServiceId != null) {
|
||||
setServiceIdsByName((current) => ({ ...current, [value]: optionServiceId }));
|
||||
if (selected.length === 0) setPrimaryServiceId(optionServiceId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSelectedServiceId({
|
||||
unavailable,
|
||||
selected,
|
||||
currentPm,
|
||||
savedServiceId,
|
||||
selectedServiceId,
|
||||
}: {
|
||||
unavailable: boolean;
|
||||
selected: string[];
|
||||
currentPm: string;
|
||||
savedServiceId?: string | number | null;
|
||||
selectedServiceId?: string | number | null;
|
||||
}) {
|
||||
if (unavailable) return savedServiceId ?? undefined;
|
||||
if (
|
||||
selected[0] === currentPm &&
|
||||
String(savedServiceId ?? "") === String(selectedServiceId ?? "")
|
||||
) {
|
||||
return savedServiceId ?? undefined;
|
||||
}
|
||||
if (selected[0] != null && selectedServiceId != null) return selectedServiceId;
|
||||
if (selected[0] === currentPm && savedServiceId != null) return savedServiceId;
|
||||
return savedServiceId == null ? undefined : null;
|
||||
}
|
||||
|
||||
function getServiceOptionState(
|
||||
services: Service[] | undefined,
|
||||
query: ServiceRegistryQueryState | undefined,
|
||||
) {
|
||||
const managed = services !== undefined;
|
||||
return {
|
||||
unavailable: managed && query != null && !query.hasData,
|
||||
empty:
|
||||
managed &&
|
||||
query?.hasData === true &&
|
||||
!query.isLoading &&
|
||||
!query.isError &&
|
||||
services.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRenamedPrimaryName({
|
||||
selectedName,
|
||||
selectedServiceId,
|
||||
serviceIdsByName,
|
||||
services,
|
||||
previous,
|
||||
pm,
|
||||
serviceId,
|
||||
}: {
|
||||
selectedName: string | undefined;
|
||||
selectedServiceId?: string | number | null;
|
||||
serviceIdsByName: Record<string, string | number>;
|
||||
services?: Service[];
|
||||
previous: DialogTarget;
|
||||
pm: string;
|
||||
serviceId?: string | number | null;
|
||||
}) {
|
||||
const chosenService = services?.find(
|
||||
(service) => String(service.id) === String(selectedServiceId),
|
||||
);
|
||||
const selectionTracksChosenId =
|
||||
selectedName != null &&
|
||||
selectedServiceId != null &&
|
||||
String(serviceIdsByName[selectedName]) === String(selectedServiceId);
|
||||
if (selectionTracksChosenId && chosenService != null) return chosenService.name;
|
||||
|
||||
const savedServiceUnchanged =
|
||||
previous.serviceId != null && String(previous.serviceId) === String(serviceId);
|
||||
const selectedSavedId = String(selectedServiceId ?? "") === String(serviceId ?? "");
|
||||
const selectedSavedLabel = selectedName === previous.pm;
|
||||
return savedServiceUnchanged && selectedSavedId && selectedSavedLabel && previous.pm !== pm
|
||||
? pm
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function useServiceNotesDraft({
|
||||
open,
|
||||
target,
|
||||
pm,
|
||||
serviceId,
|
||||
extraServices,
|
||||
serviceNotes,
|
||||
avetaRequired,
|
||||
services,
|
||||
}: {
|
||||
open: boolean;
|
||||
target: DialogTarget;
|
||||
pm: string;
|
||||
serviceId: string | number | null | undefined;
|
||||
extraServices: string[];
|
||||
serviceNotes: string;
|
||||
avetaRequired: boolean;
|
||||
services?: Service[];
|
||||
}) {
|
||||
const [sel, setSel] = useState<string[]>([]);
|
||||
const [note, setNote] = useState("");
|
||||
const [aveta, setAveta] = useState(false);
|
||||
const [primaryServiceId, setPrimaryServiceId] = useState<string | number | null | undefined>();
|
||||
const [serviceIdsByName, setServiceIdsByName] = useState<Record<string, string | number>>({});
|
||||
const [search, setSearch] = useState("");
|
||||
const lastTarget = useRef<DialogTarget | null>(null);
|
||||
const { key: targetKey, pm: targetPm, serviceId: targetServiceId } = target;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
lastTarget.current = null;
|
||||
return;
|
||||
}
|
||||
const previous = lastTarget.current;
|
||||
if (previous == null || previous.key !== targetKey) {
|
||||
setSel(selectedServices(pm, extraServices));
|
||||
setPrimaryServiceId(serviceId);
|
||||
setServiceIdsByName({
|
||||
...(serviceId != null ? { [pm]: serviceId } : {}),
|
||||
...Object.fromEntries(
|
||||
extraServices.flatMap((name) => {
|
||||
const service = services?.find((item) => item.name === name);
|
||||
return service == null ? [] : [[name, service.id]];
|
||||
}),
|
||||
),
|
||||
});
|
||||
setNote(serviceNotes ?? "");
|
||||
setAveta(Boolean(avetaRequired));
|
||||
setSearch("");
|
||||
} else {
|
||||
const renamedPrimary = resolveRenamedPrimaryName({
|
||||
selectedName: sel[0],
|
||||
selectedServiceId: primaryServiceId,
|
||||
serviceIdsByName,
|
||||
services,
|
||||
previous,
|
||||
pm,
|
||||
serviceId,
|
||||
});
|
||||
if (renamedPrimary != null && renamedPrimary !== sel[0]) {
|
||||
setSel((current) =>
|
||||
current[0] === sel[0] ? [renamedPrimary, ...current.slice(1)] : current,
|
||||
);
|
||||
setServiceIdsByName((current) => ({
|
||||
...current,
|
||||
[renamedPrimary]: primaryServiceId ?? serviceId!,
|
||||
}));
|
||||
}
|
||||
}
|
||||
lastTarget.current = { key: targetKey, pm: targetPm, serviceId: targetServiceId };
|
||||
}, [
|
||||
open,
|
||||
targetKey,
|
||||
targetPm,
|
||||
targetServiceId,
|
||||
pm,
|
||||
serviceId,
|
||||
sel,
|
||||
primaryServiceId,
|
||||
serviceIdsByName,
|
||||
extraServices,
|
||||
services,
|
||||
serviceNotes,
|
||||
avetaRequired,
|
||||
]);
|
||||
|
||||
return {
|
||||
sel,
|
||||
setSel,
|
||||
note,
|
||||
setNote,
|
||||
aveta,
|
||||
setAveta,
|
||||
search,
|
||||
setSearch,
|
||||
primaryServiceId,
|
||||
setPrimaryServiceId,
|
||||
serviceIdsByName,
|
||||
setServiceIdsByName,
|
||||
};
|
||||
}
|
||||
|
||||
export function ServiceNotesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -34,30 +358,55 @@ export function ServiceNotesDialog({
|
|||
avetaRequired = false,
|
||||
woNumber,
|
||||
pmTypes,
|
||||
services,
|
||||
serviceId,
|
||||
serviceQuery,
|
||||
viewOnly = false,
|
||||
onSave,
|
||||
}: ServiceNotesDialogProps) {
|
||||
const [sel, setSel] = useState<string[]>([]);
|
||||
const [note, setNote] = useState("");
|
||||
const [aveta, setAveta] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setSel([pm, ...(extraServices ?? [])].filter((p, i, a) => !!p && a.indexOf(p) === i));
|
||||
setNote(serviceNotes ?? "");
|
||||
setAveta(Boolean(avetaRequired));
|
||||
setSearch("");
|
||||
}, [open, pm, extraServices, serviceNotes, avetaRequired]);
|
||||
|
||||
const toggle = (value: string) => {
|
||||
setSel((cur) => (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value]));
|
||||
};
|
||||
|
||||
const services = pmTypes.map((value) => ({ id: value, value }));
|
||||
const target = getDialogTarget(woNumber, serviceId, pm);
|
||||
const {
|
||||
sel,
|
||||
setSel,
|
||||
note,
|
||||
setNote,
|
||||
aveta,
|
||||
setAveta,
|
||||
search,
|
||||
setSearch,
|
||||
primaryServiceId,
|
||||
setPrimaryServiceId,
|
||||
serviceIdsByName,
|
||||
setServiceIdsByName,
|
||||
} = useServiceNotesDraft({
|
||||
open,
|
||||
target,
|
||||
pm,
|
||||
serviceId,
|
||||
extraServices,
|
||||
serviceNotes,
|
||||
avetaRequired,
|
||||
services,
|
||||
});
|
||||
|
||||
const toggle = createServiceSelectionToggle({
|
||||
services,
|
||||
selected: sel,
|
||||
primaryServiceId,
|
||||
serviceIdsByName,
|
||||
setSelected: setSel,
|
||||
setPrimaryServiceId,
|
||||
setServiceIdsByName,
|
||||
});
|
||||
const optionState = getServiceOptionState(services, serviceQuery);
|
||||
const saveServiceId = () =>
|
||||
resolveSelectedServiceId({
|
||||
unavailable: optionState.unavailable,
|
||||
selected: sel,
|
||||
currentPm: pm,
|
||||
savedServiceId: serviceId,
|
||||
selectedServiceId: primaryServiceId,
|
||||
});
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
|
|
@ -83,19 +432,24 @@ export function ServiceNotesDialog({
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ paddingTop: 8 }}
|
||||
>
|
||||
<fieldset disabled={viewOnly} className="min-w-0 space-y-4 border-0 p-0">
|
||||
<WizardServiceNotesFields
|
||||
services={services}
|
||||
sel={sel}
|
||||
onToggle={toggle}
|
||||
note={note}
|
||||
setNote={setNote}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
avetaRequired={aveta}
|
||||
onAvetaRequiredChange={setAveta}
|
||||
/>
|
||||
</fieldset>
|
||||
<ServiceNotesSelectionFields
|
||||
pm={pm}
|
||||
extraServices={extraServices}
|
||||
pmTypes={pmTypes}
|
||||
services={services}
|
||||
serviceQuery={serviceQuery}
|
||||
serviceOptionsUnavailable={optionState.unavailable}
|
||||
serviceOptionsEmpty={optionState.empty}
|
||||
sel={sel}
|
||||
onToggle={toggle}
|
||||
note={note}
|
||||
setNote={setNote}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
aveta={aveta}
|
||||
setAveta={setAveta}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
{viewOnly ? (
|
||||
<Button
|
||||
|
|
@ -121,6 +475,7 @@ export function ServiceNotesDialog({
|
|||
onClick={() => {
|
||||
onSave({
|
||||
pm: sel[0] ?? "",
|
||||
serviceId: saveServiceId(),
|
||||
extraServices: sel.slice(1),
|
||||
serviceNotes: note,
|
||||
avetaRequired: aveta,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
|
||||
type ServiceRegistryStatusProps = {
|
||||
query?: ServiceRegistryQueryState;
|
||||
unavailable?: boolean;
|
||||
empty?: boolean;
|
||||
emptyMessage?: string;
|
||||
};
|
||||
|
||||
export function ServiceRegistryStatus({
|
||||
query,
|
||||
unavailable = false,
|
||||
empty = false,
|
||||
emptyMessage = "No services are available.",
|
||||
}: ServiceRegistryStatusProps) {
|
||||
if (query?.isLoading && !query.hasData) {
|
||||
return (
|
||||
<div role="status" aria-label="Loading services">
|
||||
Loading services…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (query?.isError) {
|
||||
return (
|
||||
<div role="alert" aria-label="Service options unavailable">
|
||||
<QueryErrorPanel
|
||||
compact
|
||||
message="Service options couldn't load. Retry to load registry services."
|
||||
onRetry={query.retry}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (query?.isFetching && query.hasData) {
|
||||
return (
|
||||
<div role="status" aria-label="Updating services">
|
||||
Updating services…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (empty && query?.hasData) {
|
||||
return <div role="status">{emptyMessage}</div>;
|
||||
}
|
||||
if (unavailable) {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground" role="status">
|
||||
Service history is unavailable. Retry before changing the work-order type.
|
||||
<button type="button" className="ml-1 underline" onClick={query?.retry}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -13,12 +13,17 @@ import { HoverChevron } from "./hover-chevron";
|
|||
import { SeverityBadge } from "./severity-badge";
|
||||
import { TypeSeverityModal } from "./type-severity-modal";
|
||||
import { WoTableTypeLabel } from "./wo-table-type-label";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
import { ServiceRegistryStatus } from "./service-registry-status";
|
||||
|
||||
type TypeCellProps = {
|
||||
value: WOType | "";
|
||||
severity?: WorkOrderSeverity | null;
|
||||
q?: string;
|
||||
viewOnly?: boolean;
|
||||
registryUnavailable?: boolean;
|
||||
registryMissing?: boolean;
|
||||
serviceHistoryQuery?: ServiceRegistryQueryState;
|
||||
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
|
||||
};
|
||||
|
||||
|
|
@ -29,7 +34,16 @@ function TypeCellValue({ value, q }: { value: WOType | ""; q?: string }) {
|
|||
return <span className="text-[13px] text-[var(--color-text-muted)]">Select type of WO</span>;
|
||||
}
|
||||
|
||||
export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }: TypeCellProps) {
|
||||
export function TypeCell({
|
||||
value,
|
||||
severity = null,
|
||||
q,
|
||||
viewOnly = false,
|
||||
registryUnavailable = false,
|
||||
registryMissing = false,
|
||||
serviceHistoryQuery,
|
||||
onSave,
|
||||
}: TypeCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalSeed, setModalSeed] = useState<WOType | "">(value);
|
||||
|
|
@ -65,6 +79,7 @@ export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }
|
|||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={registryUnavailable}
|
||||
aria-label={value ? `Type of WO, ${value}` : "Select type of WO"}
|
||||
title={value ? undefined : "Select type of WO"}
|
||||
onClick={(e) => {
|
||||
|
|
@ -77,12 +92,16 @@ export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }
|
|||
{Boolean(showSeverityBadge && severity != null) && (
|
||||
<SeverityBadge
|
||||
severity={severity!}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setModalSeed(value);
|
||||
setResetSeverity(false);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
onClick={
|
||||
registryUnavailable
|
||||
? undefined
|
||||
: (e) => {
|
||||
e.stopPropagation();
|
||||
setModalSeed(value);
|
||||
setResetSeverity(false);
|
||||
setModalOpen(true);
|
||||
}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<HoverChevron />
|
||||
|
|
@ -109,6 +128,14 @@ export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }
|
|||
))}
|
||||
</Popover>
|
||||
|
||||
{registryUnavailable && <ServiceRegistryStatus query={serviceHistoryQuery} unavailable />}
|
||||
{registryMissing && (
|
||||
<div className="text-xs text-muted-foreground" role="status">
|
||||
This linked service is no longer in the registry. Changing type clears its assignment; add
|
||||
a replacement in Service & Notes if needed.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TypeSeverityModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import type {
|
|||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import { serviceSupportsWorkOrderType } from "@/domain/services/utils/services-for-work-order-type";
|
||||
import {
|
||||
formatSeverityEscalationToast,
|
||||
shouldAnnounceSeverityEscalation,
|
||||
|
|
@ -16,18 +18,37 @@ type PersistPatchFn = (
|
|||
options?: { onSuccess?: () => void },
|
||||
) => void;
|
||||
|
||||
export function saveExistingRowType(
|
||||
onPatch: PersistPatchFn,
|
||||
row: Pick<WorkOrderTableRow, "id" | "woNumber" | "severity">,
|
||||
type: WOType,
|
||||
severity: WorkOrderSeverity | null,
|
||||
) {
|
||||
const patch = { type, severity };
|
||||
type SaveExistingRowTypeOptions = {
|
||||
onPatch: PersistPatchFn;
|
||||
row: Pick<WorkOrderTableRow, "id" | "woNumber" | "severity" | "serviceId">;
|
||||
type: WOType;
|
||||
severity: WorkOrderSeverity | null;
|
||||
serviceHistory?: Service[];
|
||||
serviceHistoryReady?: boolean;
|
||||
};
|
||||
|
||||
export function saveExistingRowType({
|
||||
onPatch,
|
||||
row,
|
||||
type,
|
||||
severity,
|
||||
serviceHistory = [],
|
||||
serviceHistoryReady = true,
|
||||
}: SaveExistingRowTypeOptions): boolean {
|
||||
const selectedService = serviceHistoryReady
|
||||
? serviceHistory.find((service) => String(service.id) === String(row.serviceId))
|
||||
: undefined;
|
||||
const clearService =
|
||||
row.serviceId != null &&
|
||||
(selectedService == null || !serviceSupportsWorkOrderType(selectedService, type));
|
||||
if (row.serviceId != null && !serviceHistoryReady) return false;
|
||||
const patch = clearService ? { type, severity, serviceId: null, pm: "" } : { type, severity };
|
||||
if (!shouldAnnounceSeverityEscalation(row.severity, severity)) {
|
||||
onPatch(row.id, patch);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
onPatch(row.id, patch, {
|
||||
onSuccess: () => toast.warn(formatSeverityEscalationToast(row.woNumber, severity)),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export function toInlineDraftRow(draft: WorkOrderTableDraft) {
|
|||
completedDate: "",
|
||||
dueDate: draft.dueDate,
|
||||
pm: draft.pm,
|
||||
serviceId: draft.serviceId,
|
||||
serviceNotes: draft.serviceNotes,
|
||||
extraServices: draft.extraServices,
|
||||
company: draft.company,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import type {
|
|||
WorkOrderTableDraft,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
import { isServiceEligibleForWorkOrderType } from "@/domain/services/utils/services-for-work-order-type";
|
||||
import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch";
|
||||
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||
import { ApptTimeCell } from "./cells/appt-time-cell";
|
||||
import { DateCell } from "./cells/date-cell";
|
||||
|
|
@ -37,6 +41,8 @@ type WoTableInlineRowCellsProps = {
|
|||
sitePlace?: { city: string; state: string };
|
||||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
services?: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
allRows: { id: string | number; woNumber: string }[];
|
||||
siteCellRef: RefObject<HTMLTableCellElement | null>;
|
||||
woCellRef: RefObject<HTMLTableCellElement | null>;
|
||||
|
|
@ -61,17 +67,30 @@ function InlineFieldCell({ children }: { children: ReactNode }) {
|
|||
|
||||
function InlineTypeSeverityCell({
|
||||
draft,
|
||||
services = [],
|
||||
onPatch,
|
||||
}: {
|
||||
draft: WorkOrderTableDraft;
|
||||
services: Service[];
|
||||
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||
}) {
|
||||
const selectedService = services.find(
|
||||
(service) => String(service.id) === String(draft.serviceId),
|
||||
);
|
||||
return (
|
||||
<InlineFieldCell>
|
||||
<TypeCell
|
||||
value={draft.type}
|
||||
severity={draft.severity}
|
||||
onSave={(t, sev) => onPatch({ type: t, severity: sev })}
|
||||
onSave={(t, sev) =>
|
||||
onPatch(
|
||||
draft.serviceId != null &&
|
||||
selectedService != null &&
|
||||
!isServiceEligibleForWorkOrderType(selectedService, t)
|
||||
? { type: t, severity: sev, serviceId: null, pm: "" }
|
||||
: { type: t, severity: sev },
|
||||
)
|
||||
}
|
||||
/>
|
||||
</InlineFieldCell>
|
||||
);
|
||||
|
|
@ -174,6 +193,8 @@ export function WoTableInlineRowCells({
|
|||
sitePlace,
|
||||
vendors,
|
||||
pmTypes,
|
||||
services = [],
|
||||
serviceQuery,
|
||||
allRows,
|
||||
siteCellRef,
|
||||
woCellRef,
|
||||
|
|
@ -202,7 +223,7 @@ export function WoTableInlineRowCells({
|
|||
onDuplicateFound={onDuplicateFound}
|
||||
/>
|
||||
|
||||
<InlineTypeSeverityCell draft={draft} onPatch={onPatch} />
|
||||
<InlineTypeSeverityCell draft={draft} services={services} onPatch={onPatch} />
|
||||
|
||||
<InlineFieldCell>
|
||||
<AssignedToCell
|
||||
|
|
@ -227,14 +248,18 @@ export function WoTableInlineRowCells({
|
|||
<PMTypeCell
|
||||
value={draft.pm}
|
||||
pmTypes={pmTypes}
|
||||
services={services}
|
||||
serviceQuery={serviceQuery}
|
||||
serviceId={draft.serviceId}
|
||||
workOrderType={draft.type}
|
||||
serviceNotes={draft.serviceNotes}
|
||||
extraServices={draft.extraServices}
|
||||
avetaRequired={draft.avetaRequired}
|
||||
woNumber={draft.woNumber}
|
||||
onSave={(pm) => onPatch({ pm })}
|
||||
onSave={(pm, id) => onPatch(id != null ? { pm, serviceId: id } : { pm })}
|
||||
onSaveDetails={(d) =>
|
||||
onPatch({
|
||||
pm: d.pm,
|
||||
...serviceIdentityPatch(draft.serviceId, draft.pm, d.pm, d.serviceId),
|
||||
extraServices: d.extraServices,
|
||||
serviceNotes: d.serviceNotes,
|
||||
avetaRequired: d.avetaRequired,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import type {
|
|||
WorkOrderTableDraft,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
import {
|
||||
UNASSIGNED_DAY_KEY,
|
||||
UNSCHEDULED_DAY_KEY,
|
||||
|
|
@ -33,6 +35,8 @@ type WoTableInlineRowProps = {
|
|||
sitePlace?: { city: string; state: string };
|
||||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
services?: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
allRows: { id: string | number; woNumber: string }[];
|
||||
saving?: boolean;
|
||||
onChange: (draft: WorkOrderTableDraft) => void;
|
||||
|
|
@ -62,6 +66,8 @@ export function WoTableInlineRow({
|
|||
sitePlace,
|
||||
vendors,
|
||||
pmTypes,
|
||||
services = [],
|
||||
serviceQuery,
|
||||
allRows,
|
||||
saving = false,
|
||||
onChange,
|
||||
|
|
@ -136,6 +142,8 @@ export function WoTableInlineRow({
|
|||
sitePlace={sitePlace}
|
||||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
services={services}
|
||||
serviceQuery={serviceQuery}
|
||||
allRows={allRows}
|
||||
siteCellRef={siteCellRef}
|
||||
woCellRef={woCellRef}
|
||||
|
|
|
|||
|
|
@ -17,12 +17,18 @@ import { SiteCell } from "./cells/site-cell";
|
|||
import { TypeCell } from "./cells/type-cell";
|
||||
import type { WoTableRowHandlers } from "./wo-table-row";
|
||||
import { resolveDueDateFloor } from "@/domain/work-orders/utils/due-date-floor";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
|
||||
type WoTableRowCellsProps = {
|
||||
row: WorkOrderTableRow;
|
||||
allRows: WorkOrderTableRow[];
|
||||
users: WorkOrderUser[];
|
||||
sites: LocationOption[];
|
||||
services?: Service[];
|
||||
serviceHistory?: Service[];
|
||||
serviceHistoryReady?: boolean;
|
||||
serviceHistoryQuery?: ServiceRegistryQueryState;
|
||||
sitePlace?: { city: string; state: string };
|
||||
search?: string;
|
||||
forceScheduleHighlight?: boolean;
|
||||
|
|
@ -34,11 +40,73 @@ type WoTableRowCellsProps = {
|
|||
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
|
||||
};
|
||||
|
||||
type WoTableRowTypeCellProps = Pick<
|
||||
WoTableRowCellsProps,
|
||||
| "row"
|
||||
| "services"
|
||||
| "serviceHistory"
|
||||
| "serviceHistoryReady"
|
||||
| "serviceHistoryQuery"
|
||||
| "search"
|
||||
| "handlers"
|
||||
| "mutationCellStyle"
|
||||
>;
|
||||
|
||||
function WoTableRowTypeCell({
|
||||
row,
|
||||
services = [],
|
||||
serviceHistory,
|
||||
serviceHistoryReady = true,
|
||||
serviceHistoryQuery,
|
||||
search,
|
||||
handlers,
|
||||
mutationCellStyle,
|
||||
}: WoTableRowTypeCellProps) {
|
||||
const typeServices = serviceHistory ?? services;
|
||||
const linkedServiceUnavailable = row.serviceId != null && !serviceHistoryReady;
|
||||
const linkedServiceMissing =
|
||||
row.serviceId != null &&
|
||||
serviceHistoryReady &&
|
||||
!typeServices.some((service) => String(service.id) === String(row.serviceId));
|
||||
|
||||
return (
|
||||
<td
|
||||
className="group/cell"
|
||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<TypeCell
|
||||
value={row.type}
|
||||
severity={row.severity}
|
||||
q={search}
|
||||
viewOnly={isWorkOrderCompletionSurfaceReadOnly(row.status)}
|
||||
registryUnavailable={linkedServiceUnavailable}
|
||||
registryMissing={linkedServiceMissing}
|
||||
serviceHistoryQuery={serviceHistoryQuery}
|
||||
onSave={(type, severity) =>
|
||||
saveExistingRowType({
|
||||
onPatch: handlers.onPatch,
|
||||
row,
|
||||
type,
|
||||
severity,
|
||||
serviceHistory: typeServices,
|
||||
serviceHistoryReady,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
export function WoTableRowCells({
|
||||
row,
|
||||
allRows,
|
||||
users,
|
||||
sites,
|
||||
services = [],
|
||||
serviceHistory,
|
||||
serviceHistoryReady = true,
|
||||
serviceHistoryQuery,
|
||||
sitePlace,
|
||||
search,
|
||||
forceScheduleHighlight,
|
||||
|
|
@ -109,19 +177,16 @@ export function WoTableRowCells({
|
|||
onDuplicateFound={handlers.onDuplicateFound}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className="group/cell"
|
||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TypeCell
|
||||
value={row.type}
|
||||
severity={row.severity}
|
||||
q={search}
|
||||
viewOnly={isWorkOrderCompletionSurfaceReadOnly(row.status)}
|
||||
onSave={(t, sev) => saveExistingRowType(handlers.onPatch, row, t, sev)}
|
||||
/>
|
||||
</td>
|
||||
<WoTableRowTypeCell
|
||||
row={row}
|
||||
services={services}
|
||||
serviceHistory={serviceHistory}
|
||||
serviceHistoryReady={serviceHistoryReady}
|
||||
serviceHistoryQuery={serviceHistoryQuery}
|
||||
search={search}
|
||||
handlers={handlers}
|
||||
mutationCellStyle={mutationCellStyle}
|
||||
/>
|
||||
<td
|
||||
className="group/cell"
|
||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||
|
|
|
|||
|
|
@ -13,11 +13,16 @@ import { isWorkOrderUpliftsReadOnly } from "@/domain/work-orders/utils/uplift-di
|
|||
import { applyVendorTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts";
|
||||
import { effectiveCompany } from "@/domain/work-orders/utils/effective-completion-snapshot";
|
||||
import type { WoTableRowHandlers } from "./wo-table-row";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch";
|
||||
|
||||
type WoTableRowServiceCellsProps = {
|
||||
row: WorkOrderTableRow;
|
||||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
services?: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
search?: string;
|
||||
mutationCellStyle?: CSSProperties;
|
||||
viewOnly?: boolean;
|
||||
|
|
@ -30,6 +35,8 @@ export function WoTableRowServiceCells({
|
|||
row,
|
||||
vendors,
|
||||
pmTypes,
|
||||
services = [],
|
||||
serviceQuery,
|
||||
search,
|
||||
mutationCellStyle,
|
||||
viewOnly = false,
|
||||
|
|
@ -49,6 +56,10 @@ export function WoTableRowServiceCells({
|
|||
<PMTypeCell
|
||||
value={row.pm}
|
||||
pmTypes={pmTypes}
|
||||
services={services}
|
||||
serviceQuery={serviceQuery}
|
||||
serviceId={row.serviceId}
|
||||
workOrderType={row.type}
|
||||
q={search}
|
||||
serviceNotes={row.serviceNotes}
|
||||
extraServices={row.extraServices}
|
||||
|
|
@ -56,10 +67,10 @@ export function WoTableRowServiceCells({
|
|||
avetaAttached={row.hasAvetaDocument}
|
||||
woNumber={row.woNumber}
|
||||
viewOnly={viewOnly}
|
||||
onSave={(pm) => onPatchRow({ pm })}
|
||||
onSave={(pm, id) => onPatchRow(id != null ? { pm, serviceId: id } : { pm })}
|
||||
onSaveDetails={(d) =>
|
||||
onPatchRow({
|
||||
pm: d.pm,
|
||||
...serviceIdentityPatch(row.serviceId, row.pm, d.pm, d.serviceId),
|
||||
extraServices: d.extraServices,
|
||||
serviceNotes: d.serviceNotes,
|
||||
avetaRequired: d.avetaRequired,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import {
|
|||
resolveFirstCellShadow,
|
||||
resolveWoTableRowStyle,
|
||||
} from "./wo-table-row-style";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
|
||||
export type WoTableRowHandlers = {
|
||||
onRowClick: (row: WorkOrderTableRow) => void;
|
||||
|
|
@ -59,6 +61,11 @@ type WoTableRowProps = {
|
|||
sitePlace?: { city: string; state: string };
|
||||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
services?: Service[];
|
||||
serviceHistory?: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryReady?: boolean;
|
||||
search?: string;
|
||||
forceScheduleHighlight?: boolean;
|
||||
forceDueDateHighlight?: boolean;
|
||||
|
|
@ -101,6 +108,11 @@ export function WoTableRow({
|
|||
sitePlace,
|
||||
vendors,
|
||||
pmTypes,
|
||||
services = [],
|
||||
serviceHistory = [],
|
||||
serviceQuery,
|
||||
serviceHistoryQuery,
|
||||
serviceHistoryReady = true,
|
||||
search,
|
||||
forceScheduleHighlight,
|
||||
forceDueDateHighlight,
|
||||
|
|
@ -197,6 +209,10 @@ export function WoTableRow({
|
|||
allRows={allRows}
|
||||
users={users}
|
||||
sites={sites}
|
||||
services={services}
|
||||
serviceHistory={serviceHistory}
|
||||
serviceHistoryReady={serviceHistoryReady}
|
||||
serviceHistoryQuery={serviceHistoryQuery}
|
||||
sitePlace={sitePlace}
|
||||
search={search}
|
||||
forceScheduleHighlight={forceScheduleHighlight}
|
||||
|
|
@ -210,6 +226,8 @@ export function WoTableRow({
|
|||
<WoTableRowServiceCells
|
||||
row={row}
|
||||
vendors={vendors}
|
||||
services={services}
|
||||
serviceQuery={serviceQuery}
|
||||
pmTypes={pmTypes}
|
||||
search={search}
|
||||
mutationCellStyle={coreLockStyle}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import { WoTableInlineRow } from "./wo-table-inline-row";
|
|||
import { WoTableRow } from "./wo-table-row";
|
||||
import { WoTableEmptyState, WoTableShell } from "./wo-table-shell";
|
||||
import { WO_TABLE_MIN_WIDTH } from "./wo-table-cols";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
|
||||
type WoTableProps = {
|
||||
tableData: ReturnTypeOfUseWorkOrdersTableData;
|
||||
|
|
@ -28,6 +30,11 @@ type WoTableProps = {
|
|||
sites: LocationOption[];
|
||||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
services?: Service[];
|
||||
serviceHistory?: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryReady?: boolean;
|
||||
weekMonday: string;
|
||||
onNavigateToDay: (dateIso: string) => void;
|
||||
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
||||
|
|
@ -87,12 +94,124 @@ function WoTablePager({ tableData }: { tableData: ReturnTypeOfUseWorkOrdersTable
|
|||
);
|
||||
}
|
||||
|
||||
type WoTableRowRenderProps = {
|
||||
row: WorkOrderTableRow;
|
||||
allRows: WorkOrderTableRow[];
|
||||
users: WorkOrderUser[];
|
||||
sites: LocationOption[];
|
||||
sitePlace?: { city: string; state: string };
|
||||
vendors: VendorDropdownItem[];
|
||||
services: Service[];
|
||||
serviceHistory: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryReady: boolean;
|
||||
pmTypes: string[];
|
||||
search: string;
|
||||
forceScheduleHighlight: boolean;
|
||||
forceDueDateHighlight: boolean;
|
||||
isSelected: boolean;
|
||||
isFlashing: boolean;
|
||||
dragViz: ReturnTypeOfUseWorkOrdersTableData["dragViz"];
|
||||
handlers: ReturnType<typeof useWoTableRowHandlers>;
|
||||
};
|
||||
|
||||
function WoTableRenderedRow(props: WoTableRowRenderProps) {
|
||||
return <WoTableRow {...props} />;
|
||||
}
|
||||
|
||||
type WoTableInlineRenderProps = {
|
||||
tableData: ReturnTypeOfUseWorkOrdersTableData;
|
||||
dayKey: string;
|
||||
users: WorkOrderUser[];
|
||||
sites: LocationOption[];
|
||||
sitePlace?: { city: string; state: string };
|
||||
vendors: VendorDropdownItem[];
|
||||
services: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
pmTypes: string[];
|
||||
saving: boolean;
|
||||
handleInlineSave: () => void;
|
||||
onDuplicateFound: (row: WorkOrderTableRow) => void;
|
||||
};
|
||||
|
||||
function WoTableRenderedInlineRow({
|
||||
tableData,
|
||||
dayKey,
|
||||
users,
|
||||
sites,
|
||||
sitePlace,
|
||||
vendors,
|
||||
services,
|
||||
serviceQuery,
|
||||
pmTypes,
|
||||
saving,
|
||||
handleInlineSave,
|
||||
onDuplicateFound,
|
||||
}: WoTableInlineRenderProps) {
|
||||
if (tableData.draftDayKey !== dayKey) return null;
|
||||
return (
|
||||
<WoTableInlineRow
|
||||
draft={tableData.draft}
|
||||
draftDayKey={tableData.draftDayKey}
|
||||
users={users}
|
||||
sites={sites}
|
||||
sitePlace={sitePlace}
|
||||
vendors={vendors}
|
||||
services={services}
|
||||
serviceQuery={serviceQuery}
|
||||
pmTypes={pmTypes}
|
||||
allRows={tableData.rows}
|
||||
saving={saving}
|
||||
onChange={tableData.setDraft}
|
||||
onSave={handleInlineSave}
|
||||
onCancel={tableData.cancelDraft}
|
||||
onDuplicateFound={onDuplicateFound}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getWoTableEmptyResult({
|
||||
tableData,
|
||||
onOpenAdvancedFilters,
|
||||
onClearSearch,
|
||||
}: {
|
||||
tableData: ReturnTypeOfUseWorkOrdersTableData;
|
||||
onOpenAdvancedFilters?: () => void;
|
||||
onClearSearch?: () => void;
|
||||
}) {
|
||||
if (tableData.advActive && tableData.scopeTotalCount === 0) {
|
||||
return (
|
||||
<WoTableEmptyShell
|
||||
message="No work orders match these filters"
|
||||
actionLabel="Adjust filters"
|
||||
onAction={onOpenAdvancedFilters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!tableData.advActive && tableData.noSearchResults) {
|
||||
return (
|
||||
<WoTableEmptyShell
|
||||
message="No work orders match your search"
|
||||
actionLabel="Clear search"
|
||||
onAction={onClearSearch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function WoTable({
|
||||
tableData,
|
||||
users,
|
||||
sites,
|
||||
vendors,
|
||||
pmTypes,
|
||||
services = [],
|
||||
serviceHistory = [],
|
||||
serviceQuery,
|
||||
serviceHistoryQuery,
|
||||
serviceHistoryReady = true,
|
||||
weekMonday,
|
||||
onNavigateToDay,
|
||||
onOpenSlideOver,
|
||||
|
|
@ -131,7 +250,7 @@ export function WoTable({
|
|||
});
|
||||
|
||||
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
|
||||
<WoTableRow
|
||||
<WoTableRenderedRow
|
||||
key={String(row.id)}
|
||||
row={row}
|
||||
allRows={tableData.rows}
|
||||
|
|
@ -141,6 +260,11 @@ export function WoTable({
|
|||
effectiveSitePlace(row) ?? resolveSitePlace(row.locationId, row.site, sitePlaceLookups)
|
||||
}
|
||||
vendors={vendors}
|
||||
services={services}
|
||||
serviceHistory={serviceHistory}
|
||||
serviceQuery={serviceQuery}
|
||||
serviceHistoryQuery={serviceHistoryQuery}
|
||||
serviceHistoryReady={serviceHistoryReady}
|
||||
pmTypes={pmTypes}
|
||||
search={tableData.search}
|
||||
forceScheduleHighlight={forceScheduleHighlight}
|
||||
|
|
@ -152,28 +276,26 @@ export function WoTable({
|
|||
/>
|
||||
);
|
||||
|
||||
const renderInlineRow = (dayKey: string) =>
|
||||
tableData.draftDayKey === dayKey && (
|
||||
<WoTableInlineRow
|
||||
draft={tableData.draft}
|
||||
draftDayKey={tableData.draftDayKey}
|
||||
users={users}
|
||||
sites={sites}
|
||||
sitePlace={resolveSitePlace(
|
||||
tableData.draft.locationId,
|
||||
tableData.draft.site,
|
||||
sitePlaceLookups,
|
||||
)}
|
||||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
allRows={tableData.rows}
|
||||
saving={saving}
|
||||
onChange={tableData.setDraft}
|
||||
onSave={handleInlineSave}
|
||||
onCancel={tableData.cancelDraft}
|
||||
onDuplicateFound={setDuplicateRow}
|
||||
/>
|
||||
);
|
||||
const renderInlineRow = (dayKey: string) => (
|
||||
<WoTableRenderedInlineRow
|
||||
tableData={tableData}
|
||||
dayKey={dayKey}
|
||||
users={users}
|
||||
sites={sites}
|
||||
sitePlace={resolveSitePlace(
|
||||
tableData.draft.locationId,
|
||||
tableData.draft.site,
|
||||
sitePlaceLookups,
|
||||
)}
|
||||
vendors={vendors}
|
||||
services={services}
|
||||
serviceQuery={serviceQuery}
|
||||
pmTypes={pmTypes}
|
||||
saving={saving}
|
||||
handleInlineSave={handleInlineSave}
|
||||
onDuplicateFound={setDuplicateRow}
|
||||
/>
|
||||
);
|
||||
|
||||
if (tableData.isLoading) {
|
||||
return (
|
||||
|
|
@ -181,29 +303,12 @@ export function WoTable({
|
|||
);
|
||||
}
|
||||
|
||||
const emptyAdv = tableData.advActive && tableData.scopeTotalCount === 0;
|
||||
|
||||
if (emptyAdv) {
|
||||
return (
|
||||
<WoTableEmptyShell
|
||||
message="No work orders match these filters"
|
||||
actionLabel="Adjust filters"
|
||||
onAction={onOpenAdvancedFilters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const showSearchEmpty = !tableData.advActive && tableData.noSearchResults;
|
||||
|
||||
if (showSearchEmpty) {
|
||||
return (
|
||||
<WoTableEmptyShell
|
||||
message="No work orders match your search"
|
||||
actionLabel="Clear search"
|
||||
onAction={onClearSearch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const emptyResult = getWoTableEmptyResult({
|
||||
tableData,
|
||||
onOpenAdvancedFilters,
|
||||
onClearSearch,
|
||||
});
|
||||
if (emptyResult) return emptyResult;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -23,8 +23,21 @@ type WorkOrdersListPagePanelsProps = {
|
|||
};
|
||||
|
||||
export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps) {
|
||||
const { filters, tableData, activeDocRow, users, locations, vendors, pmTypes, vendorTechs } =
|
||||
page;
|
||||
const {
|
||||
filters,
|
||||
tableData,
|
||||
activeDocRow,
|
||||
users,
|
||||
locations,
|
||||
vendors,
|
||||
pmTypes,
|
||||
services,
|
||||
serviceHistory,
|
||||
servicePickerState,
|
||||
serviceHistoryState,
|
||||
serviceHistoryReadyForTypeChange,
|
||||
vendorTechs,
|
||||
} = page;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -44,7 +57,8 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
|
|||
onClearAll={filters.clearAdvancedFilters}
|
||||
sites={locations}
|
||||
users={users}
|
||||
pmTypes={pmTypes}
|
||||
services={serviceHistory}
|
||||
serviceQuery={serviceHistoryState}
|
||||
vendorTechs={vendorTechs}
|
||||
/>
|
||||
|
||||
|
|
@ -58,6 +72,11 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
|
|||
sites={locations}
|
||||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
services={services}
|
||||
serviceHistory={serviceHistory}
|
||||
serviceQuery={servicePickerState}
|
||||
serviceHistoryQuery={serviceHistoryState}
|
||||
serviceHistoryReady={serviceHistoryReadyForTypeChange}
|
||||
allOrders={tableData.rows}
|
||||
onSave={(id, patch, options) => page.patchField(id, patch, options)}
|
||||
onOpenCompletionDoc={(row) => openCompletionDoc(row, page.setDocRow)}
|
||||
|
|
|
|||
|
|
@ -10,13 +10,14 @@ import {
|
|||
type WizardServiceNotesFieldsProps = {
|
||||
services: { id: string | number; value: string }[];
|
||||
sel: string[];
|
||||
onToggle: (service: string) => void;
|
||||
onToggle: (service: string, serviceId?: string | number) => void;
|
||||
note: string;
|
||||
setNote: (v: string) => void;
|
||||
search: string;
|
||||
setSearch: (v: string) => void;
|
||||
avetaRequired?: boolean;
|
||||
onAvetaRequiredChange?: (value: boolean) => void;
|
||||
serviceSelectionDisabled?: boolean;
|
||||
};
|
||||
|
||||
function SvcSectionLabel({ children }: { children: ReactNode }) {
|
||||
|
|
@ -48,6 +49,7 @@ export function WizardServiceNotesFields({
|
|||
setSearch,
|
||||
avetaRequired = false,
|
||||
onAvetaRequiredChange,
|
||||
serviceSelectionDisabled = false,
|
||||
}: WizardServiceNotesFieldsProps) {
|
||||
const filtered = search
|
||||
? services.filter((s) => s.value.toLowerCase().includes(search.toLowerCase()))
|
||||
|
|
@ -61,6 +63,7 @@ export function WizardServiceNotesFields({
|
|||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search services…"
|
||||
disabled={serviceSelectionDisabled}
|
||||
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)",
|
||||
|
|
@ -80,7 +83,8 @@ export function WizardServiceNotesFields({
|
|||
<button
|
||||
key={String(s.id)}
|
||||
type="button"
|
||||
onClick={() => onToggle(s.value)}
|
||||
onClick={() => onToggle(s.value, s.id)}
|
||||
disabled={serviceSelectionDisabled}
|
||||
className="flex w-full items-center gap-2 px-2 py-1.5 text-left transition-colors hover:bg-[#F9FAFB]"
|
||||
>
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
import {
|
||||
useActiveServices,
|
||||
useAllServices,
|
||||
} from "@/domain/services/use-cases/use-services-for-work-order-type";
|
||||
|
||||
function toServiceRegistryQueryState(
|
||||
query: UseQueryResult<Service[], Error>,
|
||||
): ServiceRegistryQueryState {
|
||||
return {
|
||||
isLoading: query.isPending && query.data === undefined,
|
||||
isError: query.isError,
|
||||
isFetching: query.isFetching,
|
||||
hasData: query.data !== undefined,
|
||||
retry: () => void query.refetch(),
|
||||
};
|
||||
}
|
||||
|
||||
export function useWorkOrderServiceRegistry() {
|
||||
const activeQuery = useActiveServices();
|
||||
const historyQuery = useAllServices();
|
||||
const servicePickerState = toServiceRegistryQueryState(activeQuery);
|
||||
const serviceHistoryState = toServiceRegistryQueryState(historyQuery);
|
||||
const serviceHistoryReadyForTypeChange = serviceHistoryState.hasData;
|
||||
|
||||
return {
|
||||
services: activeQuery.data ?? [],
|
||||
serviceHistory: historyQuery.data ?? [],
|
||||
servicePickerState,
|
||||
serviceHistoryState,
|
||||
serviceHistoryReadyForTypeChange,
|
||||
};
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||
import { useWorkOrdersListFilters } from "./use-work-orders-list-filters";
|
||||
import { useWorkOrdersTableData } from "./use-work-orders-table-data";
|
||||
import { useWorkOrderServiceRegistry } from "./use-work-order-service-registry";
|
||||
|
||||
function useSlideOverOpenState() {
|
||||
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
|
||||
|
|
@ -91,6 +92,7 @@ export function useWorkOrdersListPage() {
|
|||
const { data: locations = [] } = useWorkOrderLocations();
|
||||
const { data: vendors = [] } = useWorkOrderVendors();
|
||||
const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
|
||||
const serviceRegistry = useWorkOrderServiceRegistry();
|
||||
|
||||
const pmTypes = useMemo(
|
||||
() => problemOptions.map((option) => option.value).filter(Boolean),
|
||||
|
|
@ -195,6 +197,7 @@ export function useWorkOrdersListPage() {
|
|||
locations,
|
||||
vendors,
|
||||
pmTypes,
|
||||
...serviceRegistry,
|
||||
vendorTechs,
|
||||
activeSlideOverRow,
|
||||
activeDocRow,
|
||||
|
|
|
|||
|
|
@ -110,6 +110,11 @@ export default function WorkOrdersListPage() {
|
|||
sites={page.locations}
|
||||
vendors={page.vendors}
|
||||
pmTypes={page.pmTypes}
|
||||
services={page.services}
|
||||
serviceHistory={page.serviceHistory}
|
||||
serviceQuery={page.servicePickerState}
|
||||
serviceHistoryQuery={page.serviceHistoryState}
|
||||
serviceHistoryReady={page.serviceHistoryReadyForTypeChange}
|
||||
weekMonday={filters.weekMonday}
|
||||
onNavigateToDay={filters.navigateToDay}
|
||||
onOpenSlideOver={page.handleOpenSlideOver}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
export type ServiceRegistryQueryState = {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isFetching: boolean;
|
||||
hasData: boolean;
|
||||
retry: () => void;
|
||||
};
|
||||
|
|
@ -22,3 +22,12 @@ export function useActiveServices(): UseQueryResult<Service[], Error> {
|
|||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Registry history is available to filters so inactive historical WOs remain findable. */
|
||||
export function useAllServices(): UseQueryResult<Service[], Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.services.list(),
|
||||
queryFn: () => servicesApi.getAll(),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
19
src/domain/services/utils/services-for-work-order-type.ts
Normal file
19
src/domain/services/utils/services-for-work-order-type.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { Service, ServiceWorkOrderType } from "@/domain/services/types/service";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
export function serviceWorkOrderType(type: WOType | ""): ServiceWorkOrderType {
|
||||
return type === "Reactive" || type === "Emergency" ? type : "PM";
|
||||
}
|
||||
|
||||
export function servicesForWorkOrderType(services: Service[], type: WOType | ""): Service[] {
|
||||
return services.filter((service) => isServiceEligibleForWorkOrderType(service, type));
|
||||
}
|
||||
|
||||
export function isServiceEligibleForWorkOrderType(service: Service, type: WOType | ""): boolean {
|
||||
return service.isActive && serviceSupportsWorkOrderType(service, type);
|
||||
}
|
||||
|
||||
/** Whether a service definition supports a WO type, regardless of active assignment status. */
|
||||
export function serviceSupportsWorkOrderType(service: Service, type: WOType | ""): boolean {
|
||||
return service.supportedWorkOrderTypes.includes(serviceWorkOrderType(type));
|
||||
}
|
||||
|
|
@ -21,12 +21,18 @@ type ScalarBoardOpRule = {
|
|||
/** One row per scalar patch field, in emission order. */
|
||||
const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
|
||||
{ key: "woNumber", field: "woNumber", toValue: (value) => value as string },
|
||||
// Severity before type so Emergency/Reactive never land without required severity.
|
||||
// Severity before type, then service clear before type because the backend validates
|
||||
// the currently assigned service against the requested work-order type.
|
||||
{
|
||||
key: "severity",
|
||||
field: "severity",
|
||||
toValue: (value) => (value == null ? "" : String(value)),
|
||||
},
|
||||
{
|
||||
key: "serviceId",
|
||||
field: "serviceId",
|
||||
toValue: (value) => (value == null ? "" : String(value)),
|
||||
},
|
||||
{ key: "type", field: "workOrderType", toValue: (value) => value as string },
|
||||
{ key: "site", field: "siteCode", toValue: (value) => value as string },
|
||||
{ key: "status", field: "lifecycleStatus", toValue: (value) => value as string },
|
||||
|
|
@ -52,12 +58,27 @@ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
|
|||
},
|
||||
];
|
||||
|
||||
export function expandScalarBoardOps(patch: WorkOrderTablePatch): BoardFieldOp[] {
|
||||
export function expandScalarBoardOps(
|
||||
patch: WorkOrderTablePatch,
|
||||
currentRow?: { serviceId?: string | number | null; type?: string },
|
||||
): BoardFieldOp[] {
|
||||
const ops: BoardFieldOp[] = [];
|
||||
const reassignmentAcrossType =
|
||||
patch.type !== undefined &&
|
||||
currentRow?.type !== undefined &&
|
||||
patch.type !== currentRow.type &&
|
||||
patch.serviceId != null &&
|
||||
(currentRow.serviceId == null || String(currentRow.serviceId) !== String(patch.serviceId));
|
||||
|
||||
if (reassignmentAcrossType && currentRow.serviceId != null) {
|
||||
ops.push({ field: "serviceId", value: "" });
|
||||
}
|
||||
|
||||
for (const rule of SCALAR_BOARD_OP_RULES) {
|
||||
const value = patch[rule.key];
|
||||
if (value === undefined) continue;
|
||||
if (rule.key === "pm" && patch.serviceId !== undefined) continue;
|
||||
if (reassignmentAcrossType && rule.key === "serviceId") continue;
|
||||
ops.push({
|
||||
field: rule.field,
|
||||
value: rule.toValue(value),
|
||||
|
|
@ -65,6 +86,9 @@ export function expandScalarBoardOps(patch: WorkOrderTablePatch): BoardFieldOp[]
|
|||
? { technicianAssigned: patch.technicianAssigned }
|
||||
: {}),
|
||||
});
|
||||
if (reassignmentAcrossType && rule.key === "type") {
|
||||
ops.push({ field: "serviceId", value: String(patch.serviceId) });
|
||||
}
|
||||
}
|
||||
|
||||
return ops;
|
||||
|
|
|
|||
|
|
@ -82,12 +82,19 @@ function expandPocOp(patch: WorkOrderTablePatch): BoardPocOp[] {
|
|||
* Schedule/week-only fields are emitted first in a stable order; the composite
|
||||
* POC op follows, then scalar fields.
|
||||
*/
|
||||
export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardOp[] {
|
||||
export function expandTablePatchToBoardFieldOps(
|
||||
patch: WorkOrderTablePatch,
|
||||
currentRow?: Pick<WorkOrderTableRow, "serviceId" | "type">,
|
||||
): BoardOp[] {
|
||||
if (isLocalOnlyTablePatch(patch)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...expandScheduleBoardOps(patch), ...expandPocOp(patch), ...expandScalarBoardOps(patch)];
|
||||
return [
|
||||
...expandScheduleBoardOps(patch),
|
||||
...expandPocOp(patch),
|
||||
...expandScalarBoardOps(patch, currentRow),
|
||||
];
|
||||
}
|
||||
|
||||
export function isDispatchBoardField(field: WorkOrderBoardEditableField): boolean {
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ export type WorkOrderBoardEditableField =
|
|||
| "apptTime"
|
||||
| "docStatus"
|
||||
| "completedDate"
|
||||
| "serviceId"
|
||||
| "pm"
|
||||
| "serviceNotes"
|
||||
| "avetaRequired"
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ export interface WorkOrderTableDraft {
|
|||
location: string;
|
||||
locationId: string | number;
|
||||
pm: string;
|
||||
serviceId?: string | number | null;
|
||||
serviceNotes: string;
|
||||
extraServices: string[];
|
||||
avetaRequired?: boolean;
|
||||
|
|
@ -164,6 +165,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
|
|||
location: "",
|
||||
locationId: "",
|
||||
pm: "",
|
||||
serviceId: null,
|
||||
serviceNotes: "",
|
||||
extraServices: [],
|
||||
avetaRequired: false,
|
||||
|
|
|
|||
|
|
@ -83,12 +83,12 @@ async function applyBoardFieldOps(
|
|||
patch: WorkOrderTablePatch,
|
||||
getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined,
|
||||
): Promise<WorkOrderTableRow> {
|
||||
const ops = expandTablePatchToBoardFieldOps(patch);
|
||||
const current = getRow?.(id);
|
||||
const ops = expandTablePatchToBoardFieldOps(patch, current);
|
||||
if (ops.length === 0) {
|
||||
throw new BoardPatchValidationError("NoPersistableFields", NO_PERSISTABLE_FIELDS_MESSAGE);
|
||||
}
|
||||
|
||||
const current = getRow?.(id);
|
||||
if (!current?.rowVersion) {
|
||||
throw new Error("Missing work order version for board patch. Refresh and retry.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
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";
|
||||
|
|
@ -12,6 +13,7 @@ import { addDaysIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
|||
type AdvancedFilterPredicate = (
|
||||
row: WorkOrderTableRow,
|
||||
filters: WorkOrderAdvancedFilters,
|
||||
serviceHistory: Service[],
|
||||
) => boolean;
|
||||
|
||||
const isOpenUndated = (row: WorkOrderTableRow): boolean =>
|
||||
|
|
@ -60,8 +62,15 @@ const matchesDispatcher: AdvancedFilterPredicate = (row, filters) => {
|
|||
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 matchesPmType: AdvancedFilterPredicate = (row, filters, serviceHistory) => {
|
||||
if (!filters.pmTypes.size || filters.pmTypes.has(row.pm)) return true;
|
||||
if (row.serviceId == null) return false;
|
||||
|
||||
const linkedService = serviceHistory.find(
|
||||
(service) => String(service.id) === String(row.serviceId),
|
||||
);
|
||||
return linkedService != null && filters.pmTypes.has(linkedService.name);
|
||||
};
|
||||
|
||||
const matchesVendorTech: AdvancedFilterPredicate = (row, filters) =>
|
||||
!filters.vendorTechs.size ||
|
||||
|
|
|
|||
19
src/domain/work-orders/utils/service-identity-patch.ts
Normal file
19
src/domain/work-orders/utils/service-identity-patch.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
export function serviceIdentityPatch(
|
||||
currentServiceId: string | number | null | undefined,
|
||||
currentName: string,
|
||||
nextName: string,
|
||||
nextServiceId: string | number | null | undefined,
|
||||
): Pick<WorkOrderTablePatch, "pm" | "serviceId"> {
|
||||
const sameIdentity =
|
||||
currentServiceId != null &&
|
||||
nextServiceId != null &&
|
||||
String(currentServiceId) === String(nextServiceId);
|
||||
if (sameIdentity) return {};
|
||||
|
||||
if (nextServiceId != null) return { pm: nextName, serviceId: nextServiceId };
|
||||
if (currentServiceId != null) return { pm: nextName, serviceId: null };
|
||||
|
||||
return nextName === currentName ? {} : { pm: nextName };
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ export const SLIDE_OVER_EDIT_KEYS = [
|
|||
"woNumber",
|
||||
"site",
|
||||
"type",
|
||||
"severity",
|
||||
"dispatcherId",
|
||||
"dispatcherName",
|
||||
"scheduledOn",
|
||||
|
|
@ -26,6 +27,7 @@ export const SLIDE_OVER_EDIT_KEYS = [
|
|||
"completedDate",
|
||||
"apptTime",
|
||||
"pm",
|
||||
"serviceId",
|
||||
"serviceNotes",
|
||||
"extraServices",
|
||||
"avetaRequired",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
|
||||
import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
|
@ -36,8 +37,9 @@ export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow {
|
|||
export function matchesAdvancedFilters(
|
||||
row: WorkOrderTableRow,
|
||||
filters: WorkOrderAdvancedFilters,
|
||||
serviceHistory: Service[] = [],
|
||||
): boolean {
|
||||
return ADVANCED_FILTER_PREDICATES.every((predicate) => predicate(row, filters));
|
||||
return ADVANCED_FILTER_PREDICATES.every((predicate) => predicate(row, filters, serviceHistory));
|
||||
}
|
||||
|
||||
function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,408 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { fireEvent, screen, waitForElementToBeRemoved } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
|
||||
import { ServiceNotesDialog } from "@/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog";
|
||||
import type { ServiceNotesDetails } from "@/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog";
|
||||
import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
|
||||
import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
function serviceDefinition(id: number, name: string): Service {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: true,
|
||||
supportedWorkOrderTypes: ["PM"],
|
||||
};
|
||||
}
|
||||
|
||||
function ServiceRegistryRefreshHarness({
|
||||
withDetails,
|
||||
onSaved,
|
||||
}: {
|
||||
withDetails: boolean;
|
||||
onSaved: (details: ServiceNotesDetails) => void;
|
||||
}) {
|
||||
const [renamed, setRenamed] = useState(false);
|
||||
const [saved, setSaved] = useState<ServiceNotesDetails>({
|
||||
pm: "Original service",
|
||||
serviceId: 701,
|
||||
extraServices: [],
|
||||
serviceNotes: withDetails ? "Existing notes" : "",
|
||||
avetaRequired: false,
|
||||
});
|
||||
const primaryName = renamed ? "Renamed service" : "Original service";
|
||||
|
||||
return (
|
||||
<>
|
||||
<PMTypeCell
|
||||
value={saved.pm}
|
||||
pmTypes={[]}
|
||||
services={[serviceDefinition(701, primaryName), serviceDefinition(702, "Extra service")]}
|
||||
serviceId={saved.serviceId}
|
||||
workOrderType="PM"
|
||||
serviceNotes={saved.serviceNotes}
|
||||
extraServices={saved.extraServices}
|
||||
avetaRequired={saved.avetaRequired}
|
||||
woNumber="WO-701"
|
||||
serviceQuery={{
|
||||
isLoading: false,
|
||||
isError: !renamed,
|
||||
isFetching: false,
|
||||
hasData: true,
|
||||
retry: () => setRenamed(true),
|
||||
}}
|
||||
onSave={vi.fn()}
|
||||
onSaveDetails={(details) => {
|
||||
onSaved(details);
|
||||
setSaved(details);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Aveta Service & Notes", () => {
|
||||
it("defers linked type selection with visible loading and retry feedback", () => {
|
||||
const retry = vi.fn();
|
||||
const onSave = vi.fn();
|
||||
const query = {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
isFetching: false,
|
||||
hasData: false,
|
||||
retry,
|
||||
};
|
||||
renderWithProviders(
|
||||
<TypeCell
|
||||
value="PM"
|
||||
severity={null}
|
||||
registryUnavailable
|
||||
serviceHistoryQuery={query}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /Type of WO, PM/i })).toBeDisabled();
|
||||
expect(screen.getByRole("alert", { name: /service options unavailable/i })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows loading and preserves the current value while registry services are pending", () => {
|
||||
renderWithProviders(
|
||||
<PMTypeCell
|
||||
value="Saved service"
|
||||
pmTypes={["Leak"]}
|
||||
services={[]}
|
||||
serviceQuery={{
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
isFetching: true,
|
||||
hasData: false,
|
||||
retry: vi.fn(),
|
||||
}}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Saved service/i }));
|
||||
|
||||
expect(screen.getByRole("status", { name: /loading services/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Leak" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers a working retry and no legacy choices after registry failure", () => {
|
||||
const onRetryServices = vi.fn();
|
||||
renderWithProviders(
|
||||
<PMTypeCell
|
||||
value="Saved service"
|
||||
pmTypes={["Leak"]}
|
||||
services={[]}
|
||||
serviceQuery={{
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
isFetching: false,
|
||||
hasData: false,
|
||||
retry: onRetryServices,
|
||||
}}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Saved service/i }));
|
||||
|
||||
expect(screen.getByRole("alert", { name: /service options unavailable/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/service options couldn't load/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Leak" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(onRetryServices).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows a successful empty registry without legacy choices", () => {
|
||||
renderWithProviders(<PMTypeCell value="" pmTypes={["Leak"]} services={[]} onSave={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Select/i }));
|
||||
|
||||
expect(screen.getByText(/no services are available/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Leak" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps loaded options available while the registry refreshes", () => {
|
||||
const onSave = vi.fn();
|
||||
renderWithProviders(
|
||||
<PMTypeCell
|
||||
value="Old snapshot"
|
||||
pmTypes={["Leak"]}
|
||||
services={[
|
||||
{
|
||||
id: 701,
|
||||
name: "QA Test Service",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: true,
|
||||
supportedWorkOrderTypes: ["PM"],
|
||||
},
|
||||
]}
|
||||
serviceQuery={{
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: true,
|
||||
hasData: true,
|
||||
retry: vi.fn(),
|
||||
}}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Old snapshot/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "QA Test Service" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith("QA Test Service", 701);
|
||||
});
|
||||
|
||||
it("shows eligible registry services and saves the selected service identity", () => {
|
||||
const onSave = vi.fn();
|
||||
renderWithProviders(
|
||||
<PMTypeCell
|
||||
value="Old snapshot"
|
||||
serviceId={null}
|
||||
pmTypes={["Leak", "No Power", "Not Cooling", "Other"]}
|
||||
services={[
|
||||
{
|
||||
id: 701,
|
||||
name: "QA Test Service",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: true,
|
||||
supportedWorkOrderTypes: ["PM"],
|
||||
},
|
||||
{
|
||||
id: 702,
|
||||
name: "Reactive Only",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: true,
|
||||
supportedWorkOrderTypes: ["Reactive"],
|
||||
},
|
||||
]}
|
||||
workOrderType="PM"
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Old snapshot/i }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "QA Test Service" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Leak" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Reactive Only" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "QA Test Service" }));
|
||||
expect(onSave).toHaveBeenCalledWith("QA Test Service", 701);
|
||||
});
|
||||
|
||||
it("keeps the historical label when its linked registry service is inactive", () => {
|
||||
renderWithProviders(
|
||||
<PMTypeCell
|
||||
value="Historic Service Name"
|
||||
serviceId={701}
|
||||
pmTypes={[]}
|
||||
services={[
|
||||
{
|
||||
id: 701,
|
||||
name: "Renamed Service",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: false,
|
||||
supportedWorkOrderTypes: ["PM"],
|
||||
},
|
||||
]}
|
||||
workOrderType="PM"
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /Historic Service Name/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Renamed Service/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the completed work-order label when its active service is renamed", () => {
|
||||
renderWithProviders(
|
||||
<PMTypeCell
|
||||
value="Saved Completed Label"
|
||||
serviceId={701}
|
||||
pmTypes={[]}
|
||||
services={[
|
||||
{
|
||||
id: 701,
|
||||
name: "Current Active Name",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: true,
|
||||
supportedWorkOrderTypes: ["PM"],
|
||||
},
|
||||
]}
|
||||
workOrderType="PM"
|
||||
viewOnly
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /Saved Completed Label/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Current Active Name/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves the historic identity when only service notes change", () => {
|
||||
const onSaveDetails = vi.fn();
|
||||
renderWithProviders(
|
||||
<PMTypeCell
|
||||
value="Historic Service Name"
|
||||
serviceId={701}
|
||||
pmTypes={[]}
|
||||
serviceNotes=""
|
||||
services={[
|
||||
{
|
||||
id: 701,
|
||||
name: "Renamed Service",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: false,
|
||||
supportedWorkOrderTypes: ["PM"],
|
||||
},
|
||||
]}
|
||||
workOrderType="PM"
|
||||
onSave={vi.fn()}
|
||||
onSaveDetails={onSaveDetails}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Historic Service Name/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Add service and notes/i }));
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText(
|
||||
"Describe the specific issue, location details, door number, etc.",
|
||||
),
|
||||
{ target: { value: "Changed detail only" } },
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
const saved = onSaveDetails.mock.calls[0]![0];
|
||||
expect(saved).toMatchObject({ pm: "Historic Service Name", serviceId: 701 });
|
||||
expect(serviceIdentityPatch(701, "Historic Service Name", saved.pm, saved.serviceId)).toEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a historical service ID when an active service reuses its label", () => {
|
||||
const onSave = vi.fn();
|
||||
renderWithProviders(
|
||||
<ServiceNotesDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
pm="Retired Service"
|
||||
serviceId={1}
|
||||
extraServices={[]}
|
||||
serviceNotes="Saved notes"
|
||||
pmTypes={[]}
|
||||
services={[serviceDefinition(2, "Retired Service")]}
|
||||
serviceQuery={{
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
hasData: true,
|
||||
retry: vi.fn(),
|
||||
}}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/describe the specific issue/i), {
|
||||
target: { value: "Edited notes only" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serviceId: 1,
|
||||
pm: "Retired Service",
|
||||
serviceNotes: "Edited notes only",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces a colliding historical service only after explicit same-label selection", () => {
|
||||
const onSave = vi.fn();
|
||||
renderWithProviders(
|
||||
<ServiceNotesDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
pm="Retired Service"
|
||||
serviceId={1}
|
||||
extraServices={[]}
|
||||
serviceNotes="Saved notes"
|
||||
pmTypes={[]}
|
||||
services={[serviceDefinition(2, "Retired Service")]}
|
||||
serviceQuery={{
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
hasData: true,
|
||||
retry: vi.fn(),
|
||||
}}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Retired ServicePrimary/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ serviceId: 2, pm: "Retired Service" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the Aveta shield on the Service cell when required", () => {
|
||||
renderWithProviders(
|
||||
<PMTypeCell
|
||||
|
|
@ -63,4 +461,342 @@ describe("Aveta Service & Notes", () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows pending and successful-empty registry states without blocking notes", () => {
|
||||
const initial = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
pm: "Saved service",
|
||||
extraServices: [],
|
||||
serviceNotes: "Saved notes",
|
||||
pmTypes: ["Leak"],
|
||||
services: [] as {
|
||||
id: number;
|
||||
name: string;
|
||||
trade: string;
|
||||
iconKey: string;
|
||||
requiresCompletionDocument: boolean;
|
||||
completionDocTemplate: null;
|
||||
isActive: boolean;
|
||||
supportedWorkOrderTypes: "PM"[];
|
||||
}[],
|
||||
serviceId: 701,
|
||||
onSave: vi.fn(),
|
||||
};
|
||||
const view = renderWithProviders(
|
||||
<ServiceNotesDialog
|
||||
{...initial}
|
||||
serviceQuery={{
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
isFetching: true,
|
||||
hasData: false,
|
||||
retry: vi.fn(),
|
||||
}}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.getByRole("status", { name: /loading services/i })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/search services/i)).toBeDisabled();
|
||||
expect(screen.getByPlaceholderText(/describe the specific issue/i)).toBeEnabled();
|
||||
|
||||
view.rerender(
|
||||
<ServiceNotesDialog
|
||||
{...initial}
|
||||
serviceQuery={{
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
hasData: true,
|
||||
retry: vi.fn(),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/no services are available for this work-order type/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/describe the specific issue/i)).toBeEnabled();
|
||||
});
|
||||
|
||||
it.each(["picker add-details", "existing details trigger"])(
|
||||
"shows registry retry and keeps notes editable from the %s entry",
|
||||
(entry) => {
|
||||
const onRetry = vi.fn();
|
||||
const onSaveDetails = vi.fn();
|
||||
const props = {
|
||||
value: "Saved service",
|
||||
pmTypes: ["Leak"],
|
||||
services: [],
|
||||
serviceId: 701,
|
||||
serviceNotes: entry === "picker add-details" ? "" : "Original notes",
|
||||
extraServices: [],
|
||||
onSave: vi.fn(),
|
||||
onSaveDetails,
|
||||
serviceQuery: {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
isFetching: false,
|
||||
hasData: false,
|
||||
retry: onRetry,
|
||||
},
|
||||
};
|
||||
renderWithProviders(<PMTypeCell {...props} />, { withAuth: false });
|
||||
|
||||
if (entry === "picker add-details") {
|
||||
fireEvent.click(screen.getByRole("button", { name: /Saved service/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Add service and notes/i }));
|
||||
} else {
|
||||
fireEvent.click(screen.getByRole("button", { name: /Saved service/i }));
|
||||
}
|
||||
|
||||
expect(
|
||||
screen.getByRole("alert", { name: /service options unavailable/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
const notes = screen.getByPlaceholderText(/describe the specific issue/i);
|
||||
expect(notes).toBeEnabled();
|
||||
fireEvent.change(notes, { target: { value: "Updated notes" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onSaveDetails).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pm: "Saved service",
|
||||
serviceId: 701,
|
||||
serviceNotes: "Updated notes",
|
||||
}),
|
||||
);
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it("allows registry selection after retry resolves and saves the service ID", () => {
|
||||
const onSave = vi.fn();
|
||||
function RetryHarness() {
|
||||
const [resolved, setResolved] = useState(false);
|
||||
return (
|
||||
<ServiceNotesDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
pm="Saved service"
|
||||
extraServices={[]}
|
||||
serviceNotes=""
|
||||
pmTypes={["Leak"]}
|
||||
services={
|
||||
resolved
|
||||
? [
|
||||
{
|
||||
id: 702,
|
||||
name: "QA Test Service",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: true,
|
||||
supportedWorkOrderTypes: ["PM"],
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
serviceId={701}
|
||||
serviceQuery={{
|
||||
isLoading: false,
|
||||
isError: !resolved,
|
||||
isFetching: false,
|
||||
hasData: resolved,
|
||||
retry: () => setResolved(true),
|
||||
}}
|
||||
onSave={onSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderWithProviders(<RetryHarness />, { withAuth: false });
|
||||
expect(screen.getByRole("alert", { name: /service options unavailable/i })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/search services/i)).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(screen.getByPlaceholderText(/search services/i)).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Saved servicePrimary/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /QA Test Service/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pm: "QA Test Service", serviceId: 702 }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["picker Add service and notes", false],
|
||||
["direct details trigger", true],
|
||||
])("retains the draft and linked ID across a rename from the %s", async (_entry, withDetails) => {
|
||||
const onSaved = vi.fn();
|
||||
renderWithProviders(
|
||||
<ServiceRegistryRefreshHarness withDetails={withDetails} onSaved={onSaved} />,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Original service/i }));
|
||||
if (!withDetails) {
|
||||
fireEvent.click(screen.getByRole("button", { name: /Add service and notes/i }));
|
||||
}
|
||||
const notes = screen.getByPlaceholderText(/describe the specific issue/i);
|
||||
fireEvent.change(notes, { target: { value: "Typed while registry refreshed" } });
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /Requires Aveta documentation/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Extra service" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
expect(notes).toHaveValue("Typed while registry refreshed");
|
||||
expect(screen.getByRole("checkbox", { name: /Requires Aveta documentation/i })).toBeChecked();
|
||||
expect(screen.getByRole("button", { name: /Renamed servicePrimary/i })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onSaved).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
pm: "Renamed service",
|
||||
serviceId: 701,
|
||||
extraServices: ["Extra service"],
|
||||
serviceNotes: "Typed while registry refreshed",
|
||||
avetaRequired: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByRole("dialog"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Renamed service/i }));
|
||||
expect(screen.getByPlaceholderText(/describe the specific issue/i)).toHaveValue(
|
||||
"Typed while registry refreshed",
|
||||
);
|
||||
expect(screen.getByRole("checkbox", { name: /Requires Aveta documentation/i })).toBeChecked();
|
||||
expect(screen.getByText("Primary").closest("button")).toHaveTextContent("Renamed service");
|
||||
expect(screen.getByRole("button", { name: "Extra service" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(onSaved).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
serviceId: 701,
|
||||
extraServices: ["Extra service"],
|
||||
serviceNotes: "Typed while registry refreshed",
|
||||
avetaRequired: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a deliberate primary selection during linked-service rename", () => {
|
||||
const onSave = vi.fn();
|
||||
const common = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
extraServices: [],
|
||||
serviceNotes: "Saved notes",
|
||||
avetaRequired: false,
|
||||
pmTypes: [],
|
||||
services: [
|
||||
serviceDefinition(701, "Original service"),
|
||||
serviceDefinition(702, "Other service"),
|
||||
],
|
||||
serviceId: 701,
|
||||
serviceQuery: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
hasData: true,
|
||||
retry: vi.fn(),
|
||||
},
|
||||
onSave,
|
||||
};
|
||||
const view = renderWithProviders(<ServiceNotesDialog {...common} pm="Original service" />, {
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Original servicePrimary/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Other service" }));
|
||||
view.rerender(
|
||||
<ServiceNotesDialog
|
||||
{...common}
|
||||
pm="Renamed service"
|
||||
services={[
|
||||
serviceDefinition(701, "Renamed service"),
|
||||
serviceDefinition(702, "Other service"),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Other servicePrimary/i })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pm: "Other service", serviceId: 702 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the chosen identity when the saved same-label service is renamed", () => {
|
||||
const onSave = vi.fn();
|
||||
const common = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
extraServices: [],
|
||||
serviceNotes: "Saved notes",
|
||||
avetaRequired: false,
|
||||
pmTypes: [],
|
||||
serviceQuery: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
hasData: true,
|
||||
retry: vi.fn(),
|
||||
},
|
||||
onSave,
|
||||
};
|
||||
const view = renderWithProviders(
|
||||
<ServiceNotesDialog
|
||||
{...common}
|
||||
pm="Retired Service"
|
||||
serviceId={1}
|
||||
services={[serviceDefinition(2, "Retired Service"), serviceDefinition(3, "Extra Service")]}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Retired ServicePrimary/i }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/describe the specific issue/i), {
|
||||
target: { value: "Draft note" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /Requires Aveta documentation/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Extra Service" }));
|
||||
view.rerender(
|
||||
<ServiceNotesDialog
|
||||
{...common}
|
||||
pm="Renamed historical service"
|
||||
serviceId={1}
|
||||
services={[serviceDefinition(2, "Retired Service"), serviceDefinition(3, "Extra Service")]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Retired ServicePrimary/i })).toBeInTheDocument();
|
||||
view.rerender(
|
||||
<ServiceNotesDialog
|
||||
{...common}
|
||||
pm="Renamed historical service"
|
||||
serviceId={1}
|
||||
services={[
|
||||
serviceDefinition(2, "Renamed chosen service"),
|
||||
serviceDefinition(3, "Extra Service"),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Renamed chosen servicePrimary/i }),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pm: "Renamed chosen service",
|
||||
serviceId: 2,
|
||||
extraServices: ["Extra Service"],
|
||||
serviceNotes: "Draft note",
|
||||
avetaRequired: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,27 @@ import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers
|
|||
import { EMPTY_TABLE_DRAFT } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
describe("buildInlineDraftCreateInput", () => {
|
||||
it("preserves the selected registry service identity", () => {
|
||||
const draft = Object.assign(
|
||||
{
|
||||
...EMPTY_TABLE_DRAFT,
|
||||
site: "BK5",
|
||||
locationId: "9",
|
||||
pm: "QA Test Service",
|
||||
},
|
||||
{ serviceId: 701 },
|
||||
);
|
||||
|
||||
const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
|
||||
|
||||
expect(input.pm).toBe("QA Test Service");
|
||||
expect(input.serviceId).toBe(701);
|
||||
|
||||
const payload = mapWizardDraftToBoardCreatePayload(input);
|
||||
expect(payload.primaryService).toBe("QA Test Service");
|
||||
expect(payload.serviceId).toBe(701);
|
||||
});
|
||||
|
||||
it("defaults type to PM and omits schedule flags when undated", () => {
|
||||
const draft = {
|
||||
...EMPTY_TABLE_DRAFT,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ vi.mock("react-toastify", () => ({
|
|||
}));
|
||||
|
||||
import { saveExistingRowType } from "@/app/(protected)/workorders/_components/list/table/save-existing-row-type";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
|
||||
const row = { id: 7, woNumber: "00000024965", severity: 3 as const };
|
||||
|
||||
|
|
@ -18,7 +19,7 @@ describe("saveExistingRowType", () => {
|
|||
it("warns when severity newly enters SEV 1 only after persist onSuccess", () => {
|
||||
const onPatch = vi.fn();
|
||||
|
||||
saveExistingRowType(onPatch, row, "Emergency", 1);
|
||||
saveExistingRowType({ onPatch, row, type: "Emergency", severity: 1 });
|
||||
|
||||
expect(toastWarn).not.toHaveBeenCalled();
|
||||
expect(onPatch).toHaveBeenCalledWith(
|
||||
|
|
@ -35,7 +36,7 @@ describe("saveExistingRowType", () => {
|
|||
it("does not warn when staying on a critical severity", () => {
|
||||
const onPatch = vi.fn();
|
||||
|
||||
saveExistingRowType(onPatch, { ...row, severity: 1 }, "Emergency", 1);
|
||||
saveExistingRowType({ onPatch, row: { ...row, severity: 1 }, type: "Emergency", severity: 1 });
|
||||
|
||||
expect(toastWarn).not.toHaveBeenCalled();
|
||||
expect(onPatch).toHaveBeenCalledWith(7, { type: "Emergency", severity: 1 });
|
||||
|
|
@ -45,7 +46,7 @@ describe("saveExistingRowType", () => {
|
|||
it("does not warn when moving between SEV 3–5", () => {
|
||||
const onPatch = vi.fn();
|
||||
|
||||
saveExistingRowType(onPatch, { ...row, severity: 5 }, "Reactive", 3);
|
||||
saveExistingRowType({ onPatch, row: { ...row, severity: 5 }, type: "Reactive", severity: 3 });
|
||||
|
||||
expect(toastWarn).not.toHaveBeenCalled();
|
||||
expect(onPatch).toHaveBeenCalledWith(7, { type: "Reactive", severity: 3 });
|
||||
|
|
@ -55,10 +56,156 @@ describe("saveExistingRowType", () => {
|
|||
it("does not warn when clearing severity on a PM correction", () => {
|
||||
const onPatch = vi.fn();
|
||||
|
||||
saveExistingRowType(onPatch, { ...row, severity: 2 }, "PM", null);
|
||||
saveExistingRowType({ onPatch, row: { ...row, severity: 2 }, type: "PM", severity: null });
|
||||
|
||||
expect(toastWarn).not.toHaveBeenCalled();
|
||||
expect(onPatch).toHaveBeenCalledWith(7, { type: "PM", severity: null });
|
||||
expect(onPatch.mock.calls[0]![2]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears an active service before changing to an unsupported work-order type", () => {
|
||||
const onPatch = vi.fn();
|
||||
const services: Service[] = [
|
||||
{
|
||||
id: 701,
|
||||
name: "PM Only Service",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: true,
|
||||
supportedWorkOrderTypes: ["PM"],
|
||||
},
|
||||
];
|
||||
|
||||
saveExistingRowType({
|
||||
onPatch,
|
||||
row: { ...row, serviceId: 701 },
|
||||
type: "Emergency",
|
||||
severity: null,
|
||||
serviceHistory: services,
|
||||
});
|
||||
|
||||
expect(onPatch).toHaveBeenCalledWith(7, {
|
||||
type: "Emergency",
|
||||
severity: null,
|
||||
serviceId: null,
|
||||
pm: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a known inactive historical service that supports the new type", () => {
|
||||
const onPatch = vi.fn();
|
||||
const services: Service[] = [
|
||||
{
|
||||
id: 702,
|
||||
name: "Retired Multi-type Service",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: false,
|
||||
supportedWorkOrderTypes: ["PM", "Reactive"],
|
||||
},
|
||||
];
|
||||
|
||||
saveExistingRowType({
|
||||
onPatch,
|
||||
row: { ...row, serviceId: 702 },
|
||||
type: "Reactive",
|
||||
severity: null,
|
||||
serviceHistory: services,
|
||||
});
|
||||
|
||||
expect(onPatch).toHaveBeenCalledWith(7, { type: "Reactive", severity: null });
|
||||
});
|
||||
|
||||
it("clears a known inactive historical service before type change", () => {
|
||||
const onPatch = vi.fn();
|
||||
const services: Service[] = [
|
||||
{
|
||||
id: 701,
|
||||
name: "Retired Service",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: false,
|
||||
supportedWorkOrderTypes: ["PM"],
|
||||
},
|
||||
];
|
||||
|
||||
saveExistingRowType({
|
||||
onPatch,
|
||||
row: { ...row, serviceId: 701 },
|
||||
type: "Emergency",
|
||||
severity: null,
|
||||
serviceHistory: services,
|
||||
});
|
||||
|
||||
expect(onPatch).toHaveBeenCalledWith(7, {
|
||||
type: "Emergency",
|
||||
severity: null,
|
||||
serviceId: null,
|
||||
pm: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears a settled missing service before changing type", () => {
|
||||
const onPatch = vi.fn();
|
||||
|
||||
const saved = saveExistingRowType({
|
||||
onPatch,
|
||||
row: { ...row, serviceId: 799 },
|
||||
type: "Emergency",
|
||||
severity: 2,
|
||||
serviceHistory: [],
|
||||
serviceHistoryReady: true,
|
||||
});
|
||||
|
||||
expect(saved).toBe(true);
|
||||
expect(onPatch.mock.calls[0]?.slice(0, 2)).toEqual([
|
||||
7,
|
||||
{ type: "Emergency", severity: 2, serviceId: null, pm: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("defers a linked service type change when history has not loaded", () => {
|
||||
const onPatch = vi.fn();
|
||||
const inactiveService: Service = {
|
||||
id: 701,
|
||||
name: "Retired Service",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: false,
|
||||
supportedWorkOrderTypes: ["PM"],
|
||||
};
|
||||
|
||||
saveExistingRowType({
|
||||
onPatch,
|
||||
row: { ...row, serviceId: 701 },
|
||||
type: "Emergency",
|
||||
severity: null,
|
||||
serviceHistory: [inactiveService],
|
||||
serviceHistoryReady: false,
|
||||
});
|
||||
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows type changes for unlinked legacy rows while history is unavailable", () => {
|
||||
const onPatch = vi.fn();
|
||||
|
||||
saveExistingRowType({
|
||||
onPatch,
|
||||
row: { ...row, serviceId: null },
|
||||
type: "Emergency",
|
||||
severity: null,
|
||||
serviceHistoryReady: false,
|
||||
});
|
||||
|
||||
expect(onPatch).toHaveBeenCalledWith(7, { type: "Emergency", severity: null });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WoTableRowCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-cells";
|
||||
import { SlideOverInfoTabEditView } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/site-cell", () => ({
|
||||
SiteCell: () => null,
|
||||
}));
|
||||
vi.mock(
|
||||
"@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell",
|
||||
() => ({
|
||||
EditableWONumberCell: () => null,
|
||||
}),
|
||||
);
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({
|
||||
AssignedToCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({
|
||||
DateCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/due-date-cell", () => ({
|
||||
DueDateCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({
|
||||
ApptTimeCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({
|
||||
VendorCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({
|
||||
StatusCell: () => null,
|
||||
}));
|
||||
|
||||
function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
id: 7,
|
||||
woNumber: "WO-7",
|
||||
site: "SEA1",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Dispatcher",
|
||||
scheduledOn: "2026-07-20",
|
||||
completedDate: "",
|
||||
targetWeek: "",
|
||||
dayGroup: "07/20",
|
||||
dayLabel: "Mon",
|
||||
location: "Seattle",
|
||||
locationId: "loc-1",
|
||||
pm: "Deleted service",
|
||||
serviceId: 799,
|
||||
serviceNotes: "Keep these notes",
|
||||
extraServices: [],
|
||||
company: "Vendor",
|
||||
tech: "Tech",
|
||||
techPhone: "",
|
||||
vendorId: "v1",
|
||||
apptStart: "",
|
||||
apptEnd: "",
|
||||
apptTime: "",
|
||||
status: "Scheduled",
|
||||
docStatus: "No",
|
||||
pocName: "",
|
||||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
additionalContacts: [],
|
||||
dueDate: "",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
isAddOn: false,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
isPastDue: false,
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 0,
|
||||
title: "",
|
||||
priority: "",
|
||||
primaryDispatchId: null,
|
||||
rowVersion: "row-v1",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function chooseEmergency() {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Type of WO, PM" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Emergency" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /SEV 2/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
}
|
||||
|
||||
describe("settled missing service type recovery", () => {
|
||||
it("offers an enabled table type change and clears only the missing service link", () => {
|
||||
const onPatch = vi.fn();
|
||||
renderWithProviders(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<WoTableRowCells
|
||||
row={row()}
|
||||
allRows={[]}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
serviceHistory={[]}
|
||||
serviceHistoryReady
|
||||
handlers={{ onPatch } as unknown as WoTableRowHandlers}
|
||||
onPatchRow={vi.fn()}
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
const typeButton = screen.getByRole("button", { name: "Type of WO, PM" });
|
||||
expect(typeButton).toBeEnabled();
|
||||
expect(screen.getByText(/no longer in the registry/i)).toBeInTheDocument();
|
||||
chooseEmergency();
|
||||
|
||||
expect(onPatch.mock.calls[0]?.slice(0, 2)).toEqual([
|
||||
7,
|
||||
{ type: "Emergency", severity: 2, serviceId: null, pm: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets slide-over users recover while retaining notes and other identity fields", () => {
|
||||
const onDraftChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<SlideOverInfoTabEditView
|
||||
draft={row()}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={[]}
|
||||
services={[]}
|
||||
serviceHistory={[]}
|
||||
serviceHistoryReady
|
||||
allOrders={[]}
|
||||
onDraftChange={onDraftChange}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.getByText(/no longer in the registry/i)).toBeInTheDocument();
|
||||
chooseEmergency();
|
||||
|
||||
expect(onDraftChange).toHaveBeenCalledWith({
|
||||
type: "Emergency",
|
||||
severity: 2,
|
||||
serviceId: null,
|
||||
pm: "",
|
||||
});
|
||||
expect(onDraftChange.mock.calls[0]?.[0]).not.toHaveProperty("serviceNotes");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { useState } from "react";
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SlideOverInfoTabEditView } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view";
|
||||
import { SlideOverInfoTabDisplayView } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { expandTablePatchToBoardFieldOps } from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
|
||||
import { buildSlideOverPatch } from "@/domain/work-orders/utils/slide-over-draft";
|
||||
|
|
@ -48,7 +50,20 @@ vi.mock(
|
|||
}),
|
||||
);
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({
|
||||
TypeCell: () => null,
|
||||
TypeCell: ({
|
||||
onSave,
|
||||
}: {
|
||||
onSave: (type: "Reactive" | "Emergency", severity: number) => void;
|
||||
}) => (
|
||||
<>
|
||||
<button type="button" onClick={() => onSave("Reactive", 2)}>
|
||||
Change type to Reactive
|
||||
</button>
|
||||
<button type="button" onClick={() => onSave("Emergency", 2)}>
|
||||
Change type to Emergency
|
||||
</button>
|
||||
</>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({
|
||||
AssignedToCell: () => null,
|
||||
|
|
@ -59,9 +74,6 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", (
|
|||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({
|
||||
ApptTimeCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({
|
||||
PMTypeCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({
|
||||
VendorCell: () => null,
|
||||
}));
|
||||
|
|
@ -121,6 +133,219 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
}
|
||||
|
||||
describe("SlideOverInfoTabEditView additional contacts", () => {
|
||||
it("keeps the historic service label in slide-over display for an inactive service", () => {
|
||||
renderWithProviders(
|
||||
<SlideOverInfoTabDisplayView
|
||||
source={baseRow({ pm: "Historic Service Name", serviceId: 701 })}
|
||||
isCancelled={false}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={[]}
|
||||
services={[
|
||||
{
|
||||
id: 701,
|
||||
name: "Renamed Service",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: false,
|
||||
supportedWorkOrderTypes: ["PM"],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.getByText("Historic Service Name")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Renamed Service")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a completed slide-over's saved label after its active service is renamed", () => {
|
||||
renderWithProviders(
|
||||
<SlideOverInfoTabDisplayView
|
||||
source={baseRow({
|
||||
status: "Completed",
|
||||
pm: "Saved Completed Label",
|
||||
serviceId: 701,
|
||||
})}
|
||||
isCancelled={false}
|
||||
isCompleted
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={[]}
|
||||
services={[
|
||||
{
|
||||
id: 701,
|
||||
name: "Current Active Name",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: true,
|
||||
supportedWorkOrderTypes: ["PM"],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.getByText("Saved Completed Label")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Current Active Name")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves an incompatible type change only after clearing the registry service", () => {
|
||||
const baseline = baseRow({ type: "PM", pm: "PM only", serviceId: 701 });
|
||||
const onSave = vi.fn();
|
||||
const inactivePmOnlyService = {
|
||||
id: 701,
|
||||
name: "PM only",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: false,
|
||||
supportedWorkOrderTypes: ["PM" as const],
|
||||
};
|
||||
|
||||
function SlideOverSaveHarness() {
|
||||
const [draft, setDraft] = useState(baseline);
|
||||
return (
|
||||
<>
|
||||
<SlideOverInfoTabEditView
|
||||
draft={draft}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={[]}
|
||||
services={[]}
|
||||
serviceHistory={[inactivePmOnlyService]}
|
||||
allOrders={[]}
|
||||
onDraftChange={(patch) => setDraft((current) => ({ ...current, ...patch }))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSave(expandTablePatchToBoardFieldOps(buildSlideOverPatch(draft, baseline)))
|
||||
}
|
||||
>
|
||||
Save changes
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderWithProviders(<SlideOverSaveHarness />, { withAuth: false });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Change type to Emergency" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith([
|
||||
{ field: "severity", value: "2" },
|
||||
{ field: "serviceId", value: "" },
|
||||
{ field: "workOrderType", value: "Emergency" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves a compatible inactive service during a slide-over type change", () => {
|
||||
const baseline = baseRow({ type: "PM", pm: "Historical service", serviceId: 702 });
|
||||
const onSave = vi.fn();
|
||||
const compatibleInactiveService = {
|
||||
id: 702,
|
||||
name: "Historical service",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: false,
|
||||
supportedWorkOrderTypes: ["PM" as const, "Reactive" as const],
|
||||
};
|
||||
|
||||
function SlideOverSaveHarness() {
|
||||
const [draft, setDraft] = useState(baseline);
|
||||
return (
|
||||
<>
|
||||
<SlideOverInfoTabEditView
|
||||
draft={draft}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={[]}
|
||||
services={[]}
|
||||
serviceHistory={[compatibleInactiveService]}
|
||||
allOrders={[]}
|
||||
onDraftChange={(patch) => setDraft((current) => ({ ...current, ...patch }))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSave(expandTablePatchToBoardFieldOps(buildSlideOverPatch(draft, baseline)))
|
||||
}
|
||||
>
|
||||
Save changes
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderWithProviders(<SlideOverSaveHarness />, { withAuth: false });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Change type to Reactive" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith([
|
||||
{ field: "severity", value: "2" },
|
||||
{ field: "workOrderType", value: "Reactive" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not clear a saved service while registry history is unresolved", () => {
|
||||
const baseline = baseRow({ type: "PM", pm: "Historical service", serviceId: 703 });
|
||||
const onSave = vi.fn();
|
||||
const pmOnlyService = {
|
||||
id: 703,
|
||||
name: "Historical service",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: false,
|
||||
supportedWorkOrderTypes: ["PM" as const],
|
||||
};
|
||||
|
||||
function SlideOverSaveHarness() {
|
||||
const [draft, setDraft] = useState(baseline);
|
||||
return (
|
||||
<>
|
||||
<SlideOverInfoTabEditView
|
||||
draft={draft}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={[]}
|
||||
services={[]}
|
||||
serviceHistory={[pmOnlyService]}
|
||||
serviceHistoryReady={false}
|
||||
allOrders={[]}
|
||||
onDraftChange={(patch) => setDraft((current) => ({ ...current, ...patch }))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSave(expandTablePatchToBoardFieldOps(buildSlideOverPatch(draft, baseline)))
|
||||
}
|
||||
>
|
||||
Save changes
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderWithProviders(<SlideOverSaveHarness />, { withAuth: false });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Change type to Emergency" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("passes additionalContacts into SiteCell and persists them on save", () => {
|
||||
const onDraftChange = vi.fn();
|
||||
const contacts = [{ name: "Existing", phone: "555-0000" }];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const queryFixtures = vi.hoisted(() => ({ active: null as unknown, history: null as unknown }));
|
||||
|
||||
vi.mock("@/domain/services/use-cases/use-services-for-work-order-type", () => ({
|
||||
useActiveServices: () => queryFixtures.active,
|
||||
useAllServices: () => queryFixtures.history,
|
||||
}));
|
||||
|
||||
import { useWorkOrderServiceRegistry } from "@/app/(protected)/workorders/_hooks/use-work-order-service-registry";
|
||||
|
||||
function query(data: unknown, isFetching: boolean) {
|
||||
return {
|
||||
data,
|
||||
isPending: data === undefined,
|
||||
isError: false,
|
||||
isFetching,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("useWorkOrderServiceRegistry", () => {
|
||||
beforeEach(() => {
|
||||
queryFixtures.active = query([], false);
|
||||
queryFixtures.history = query([], false);
|
||||
});
|
||||
|
||||
it("uses cached service history while a background refresh is in progress", () => {
|
||||
const cached = [{ id: 701, name: "Cached historical service" }];
|
||||
queryFixtures.history = query(cached, true);
|
||||
|
||||
const { result } = renderHook(() => useWorkOrderServiceRegistry());
|
||||
|
||||
expect(result.current.serviceHistory).toEqual(cached);
|
||||
expect(result.current.serviceHistoryReadyForTypeChange).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat initial pending or failed queries without data as ready", () => {
|
||||
queryFixtures.history = { ...query(undefined, true), isPending: true };
|
||||
const pending = renderHook(() => useWorkOrderServiceRegistry());
|
||||
expect(pending.result.current.serviceHistoryReadyForTypeChange).toBe(false);
|
||||
pending.unmount();
|
||||
|
||||
queryFixtures.history = { ...query(undefined, false), isError: true, isPending: false };
|
||||
const failed = renderHook(() => useWorkOrderServiceRegistry());
|
||||
expect(failed.result.current.serviceHistoryReadyForTypeChange).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -52,6 +52,28 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () =>
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_hooks/use-work-order-service-registry", () => ({
|
||||
useWorkOrderServiceRegistry: () => ({
|
||||
services: [],
|
||||
serviceHistory: [],
|
||||
servicePickerState: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
hasData: true,
|
||||
retry: vi.fn(),
|
||||
},
|
||||
serviceHistoryState: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
hasData: true,
|
||||
retry: vi.fn(),
|
||||
},
|
||||
serviceHistoryReadyForTypeChange: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({
|
||||
useWorkOrderTableMutations: () => ({
|
||||
patchField,
|
||||
|
|
|
|||
|
|
@ -51,6 +51,28 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () =>
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_hooks/use-work-order-service-registry", () => ({
|
||||
useWorkOrderServiceRegistry: () => ({
|
||||
services: [],
|
||||
serviceHistory: [],
|
||||
servicePickerState: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
hasData: true,
|
||||
retry: vi.fn(),
|
||||
},
|
||||
serviceHistoryState: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
hasData: true,
|
||||
retry: vi.fn(),
|
||||
},
|
||||
serviceHistoryReadyForTypeChange: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({
|
||||
useWorkOrderTableMutations: () => ({
|
||||
patchField,
|
||||
|
|
|
|||
|
|
@ -128,6 +128,28 @@ function tableData(
|
|||
}
|
||||
|
||||
describe("WoTable advanced-search pagination", () => {
|
||||
it("renders the nonempty table body and pager", () => {
|
||||
renderWithProviders(
|
||||
<WoTable
|
||||
tableData={tableData()}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={[]}
|
||||
weekMonday="2026-05-18"
|
||||
onNavigateToDay={vi.fn()}
|
||||
onOpenSlideOver={vi.fn()}
|
||||
onOpenMedia={vi.fn()}
|
||||
onPatch={vi.fn()}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.getByText("WO-0")).toBeInTheDocument();
|
||||
expect(screen.getByText("RESULTS · 1778 items")).toBeInTheDocument();
|
||||
expect(screen.getByText("Page 1 of 36")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pages the flat Unassigned search without a pin or a 1778-row dump", () => {
|
||||
const goToNextDayPage = vi.fn();
|
||||
const data = tableData({ goToNextDayPage });
|
||||
|
|
|
|||
|
|
@ -12,6 +12,48 @@ function currentMonthIso(day: number): string {
|
|||
}
|
||||
|
||||
describe("WoTableInlineRowCells — Schedule On range", () => {
|
||||
it("keeps registry failure and retry in the inline Add WO service picker", async () => {
|
||||
const retry = vi.fn();
|
||||
renderWithProviders(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<WoTableInlineRowCells
|
||||
draft={EMPTY_TABLE_DRAFT}
|
||||
weekDefault={false}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={["Leak"]}
|
||||
services={[]}
|
||||
serviceQuery={{
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
isFetching: false,
|
||||
hasData: false,
|
||||
retry,
|
||||
}}
|
||||
allRows={[]}
|
||||
siteCellRef={createRef()}
|
||||
woCellRef={createRef()}
|
||||
siteShowError={false}
|
||||
siteError=""
|
||||
woShowError={false}
|
||||
onPatch={vi.fn()}
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Select …" }));
|
||||
|
||||
expect(screen.getByRole("alert", { name: /service options unavailable/i })).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "Leak" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reopens a company-only inline assignment without inferring its anchor as technician", async () => {
|
||||
renderWithProviders(
|
||||
<table>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,22 @@ import { renderWithProviders } from "@/test/test-utils";
|
|||
const setAdvOpen = vi.fn();
|
||||
const applyAdvancedFilters = vi.fn();
|
||||
const clearAdvancedFilters = vi.fn();
|
||||
const serviceQueries = vi.hoisted(() => ({
|
||||
active: {
|
||||
data: undefined as unknown,
|
||||
isPending: false as boolean,
|
||||
isError: false as boolean,
|
||||
isFetching: false as boolean,
|
||||
refetch: vi.fn(),
|
||||
},
|
||||
all: {
|
||||
data: undefined as unknown,
|
||||
isPending: false as boolean,
|
||||
isError: false as boolean,
|
||||
isFetching: false as boolean,
|
||||
refetch: vi.fn(),
|
||||
},
|
||||
}));
|
||||
let advOpen = false;
|
||||
let advApplied: typeof EMPTY_ADVANCED_FILTERS | null = null;
|
||||
|
||||
|
|
@ -102,9 +118,29 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
|
||||
useDropdownOptionsByCategory: () => ({ data: [{ value: "HVAC" }] }),
|
||||
useDropdownOptionsByCategory: () => ({
|
||||
data: [
|
||||
{ value: "HVAC" },
|
||||
{ value: "Leak" },
|
||||
{ value: "No Power" },
|
||||
{ value: "Not Cooling" },
|
||||
{ value: "Other" },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/services/use-cases/use-services-for-work-order-type", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@/domain/services/use-cases/use-services-for-work-order-type")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
useActiveServices: () => serviceQueries.active,
|
||||
useAllServices: () => serviceQueries.all,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/domain/work-orders/mocks/is-wo-table-mock-enabled", () => ({
|
||||
isWoTableMockEnabled: () => false,
|
||||
}));
|
||||
|
|
@ -121,11 +157,157 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
|
|||
beforeEach(() => {
|
||||
advOpen = false;
|
||||
advApplied = null;
|
||||
serviceQueries.active.data = [
|
||||
{
|
||||
id: 701,
|
||||
name: "QA Test Service",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: true,
|
||||
supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"],
|
||||
},
|
||||
];
|
||||
serviceQueries.active.isPending = false;
|
||||
serviceQueries.active.isError = false;
|
||||
serviceQueries.active.isFetching = false;
|
||||
serviceQueries.active.refetch.mockReset();
|
||||
serviceQueries.all.data = [
|
||||
{
|
||||
id: 701,
|
||||
name: "QA Test Service",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: true,
|
||||
supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"],
|
||||
},
|
||||
{
|
||||
id: 702,
|
||||
name: "Retired Service",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: false,
|
||||
supportedWorkOrderTypes: ["PM"],
|
||||
},
|
||||
];
|
||||
serviceQueries.all.isPending = false;
|
||||
serviceQueries.all.isError = false;
|
||||
serviceQueries.all.isFetching = false;
|
||||
serviceQueries.all.refetch.mockReset();
|
||||
setAdvOpen.mockClear();
|
||||
applyAdvancedFilters.mockClear();
|
||||
clearAdvancedFilters.mockClear();
|
||||
});
|
||||
|
||||
it("offers active registry services instead of legacy Problem dropdown values", () => {
|
||||
const view = renderWithProviders(<WorkOrdersListPage />, {
|
||||
route: "/workorders",
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
|
||||
advOpen = true;
|
||||
view.rerender(<WorkOrdersListPage />);
|
||||
|
||||
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /^Service/i }));
|
||||
|
||||
expect(within(sheet).getByText("QA Test Service")).toBeInTheDocument();
|
||||
expect(within(sheet).getByText("Retired Service")).toBeInTheDocument();
|
||||
expect(within(sheet).queryByText("Leak")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows loading instead of legacy values while registry history is pending", () => {
|
||||
serviceQueries.all.data = undefined;
|
||||
serviceQueries.all.isPending = true;
|
||||
serviceQueries.all.isFetching = true;
|
||||
const view = renderWithProviders(<WorkOrdersListPage />, {
|
||||
route: "/workorders",
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
|
||||
advOpen = true;
|
||||
view.rerender(<WorkOrdersListPage />);
|
||||
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /^Service/i }));
|
||||
|
||||
expect(within(sheet).getByRole("status", { name: /loading services/i })).toBeInTheDocument();
|
||||
expect(within(sheet).queryByText("Leak")).not.toBeInTheDocument();
|
||||
expect(within(sheet).getByPlaceholderText("Search service…")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows a retryable registry error and no legacy service filters on failure", () => {
|
||||
serviceQueries.all.data = undefined;
|
||||
serviceQueries.all.isError = true;
|
||||
advApplied = {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
pmTypes: new Set(["Previously selected service"]),
|
||||
};
|
||||
serviceQueries.all.refetch.mockImplementation(() => {
|
||||
serviceQueries.all.data = [
|
||||
{
|
||||
id: 701,
|
||||
name: "QA Test Service",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: true,
|
||||
supportedWorkOrderTypes: ["PM"],
|
||||
},
|
||||
];
|
||||
serviceQueries.all.isError = false;
|
||||
serviceQueries.all.isPending = false;
|
||||
serviceQueries.all.isFetching = false;
|
||||
});
|
||||
const view = renderWithProviders(<WorkOrdersListPage />, {
|
||||
route: "/workorders",
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
|
||||
advOpen = true;
|
||||
view.rerender(<WorkOrdersListPage />);
|
||||
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /^Service/i }));
|
||||
|
||||
expect(
|
||||
within(sheet).getByRole("alert", { name: /service filters unavailable/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(within(sheet).getByText(/service filters couldn't load/i)).toBeInTheDocument();
|
||||
expect(within(sheet).queryByText("Leak")).not.toBeInTheDocument();
|
||||
expect(within(sheet).getByText("Previously selected service")).toBeInTheDocument();
|
||||
expect(within(sheet).getByPlaceholderText("Search service…")).toBeDisabled();
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: "Retry" }));
|
||||
expect(serviceQueries.all.refetch).toHaveBeenCalledOnce();
|
||||
view.rerender(<WorkOrdersListPage />);
|
||||
expect(within(sheet).getByText("QA Test Service")).toBeInTheDocument();
|
||||
expect(within(sheet).getByText("Previously selected service")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a successful empty registry as empty instead of legacy choices", () => {
|
||||
serviceQueries.all.data = [];
|
||||
const view = renderWithProviders(<WorkOrdersListPage />, {
|
||||
route: "/workorders",
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
|
||||
advOpen = true;
|
||||
view.rerender(<WorkOrdersListPage />);
|
||||
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /^Service/i }));
|
||||
|
||||
expect(within(sheet).getByText(/no services are available/i)).toBeInTheDocument();
|
||||
expect(within(sheet).queryByText("Leak")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the sheet from Advanced Filters, applies, then clears", () => {
|
||||
const view = renderWithProviders(<WorkOrdersListPage />, {
|
||||
route: "/workorders",
|
||||
|
|
@ -149,7 +331,7 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
|
|||
}
|
||||
expect(within(sheet).getByText("Seattle")).toBeInTheDocument();
|
||||
expect(within(sheet).getByText("Dispatcher One")).toBeInTheDocument();
|
||||
expect(within(sheet).getByText("HVAC")).toBeInTheDocument();
|
||||
expect(within(sheet).getByText("QA Test Service")).toBeInTheDocument();
|
||||
expect(within(sheet).getByText("Vendor Co")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /Aveta Compliance/i }));
|
||||
|
|
@ -218,7 +400,7 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
|
|||
|
||||
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /^Service/i }));
|
||||
fireEvent.click(within(sheet).getByText("HVAC"));
|
||||
fireEvent.click(within(sheet).getByText("QA Test Service"));
|
||||
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /Completion doc/i }));
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: /^Pending$/i }));
|
||||
|
|
@ -230,13 +412,13 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
|
|||
expect(applyAdvancedFilters).toHaveBeenCalled();
|
||||
const appliedCalls = applyAdvancedFilters.mock.calls;
|
||||
const applied = appliedCalls[appliedCalls.length - 1]?.[0] as typeof EMPTY_ADVANCED_FILTERS;
|
||||
expect(applied.pmTypes.has("HVAC")).toBe(true);
|
||||
expect(applied.pmTypes.has("QA Test Service")).toBe(true);
|
||||
expect(applied.docs.has("No")).toBe(true);
|
||||
expect(applied.docs.has("Yes")).toBe(true);
|
||||
expect(applied.docs.has("NN")).toBe(true);
|
||||
|
||||
const params = toBoardSearchQueryParams({ filters: applied });
|
||||
expect(params.pmTypes).toEqual(["HVAC"]);
|
||||
expect(params.pmTypes).toEqual(["QA Test Service"]);
|
||||
expect(params.docStatuses).toEqual([2, 1, 3]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,19 @@ describe("expandTablePatchToBoardFieldOps", () => {
|
|||
expect(expandTablePatchToBoardFieldOps({ pm: "HVAC" })).toEqual([
|
||||
{ field: "pm", value: "HVAC" },
|
||||
]);
|
||||
expect(expandTablePatchToBoardFieldOps({ pm: "QA Test Service", serviceId: 701 })).toEqual([
|
||||
{ field: "serviceId", value: "701" },
|
||||
]);
|
||||
expect(expandTablePatchToBoardFieldOps({ serviceId: null, pm: "" })).toEqual([
|
||||
{ field: "serviceId", value: "" },
|
||||
]);
|
||||
expect(
|
||||
expandTablePatchToBoardFieldOps({ severity: 2, serviceId: null, type: "Reactive", pm: "" }),
|
||||
).toEqual([
|
||||
{ field: "severity", value: "2" },
|
||||
{ field: "serviceId", value: "" },
|
||||
{ field: "workOrderType", value: "Reactive" },
|
||||
]);
|
||||
expect(expandTablePatchToBoardFieldOps({ docStatus: "NN" })).toEqual([
|
||||
{ field: "docStatus", value: "NotNeeded" },
|
||||
]);
|
||||
|
|
@ -85,6 +98,32 @@ describe("expandTablePatchToBoardFieldOps", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("clears the current service before changing type and assigns the new service afterward", () => {
|
||||
expect(
|
||||
expandTablePatchToBoardFieldOps(
|
||||
{ severity: 2, type: "Emergency", serviceId: 702, pm: "Emergency Service" },
|
||||
{ serviceId: 701, type: "PM" },
|
||||
),
|
||||
).toEqual([
|
||||
{ field: "serviceId", value: "" },
|
||||
{ field: "severity", value: "2" },
|
||||
{ field: "workOrderType", value: "Emergency" },
|
||||
{ field: "serviceId", value: "702" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps same-type service edits as a single assignment", () => {
|
||||
expect(
|
||||
expandTablePatchToBoardFieldOps(
|
||||
{ type: "PM", serviceId: 702, pm: "PM Service B" },
|
||||
{ serviceId: 701, type: "PM" },
|
||||
),
|
||||
).toEqual([
|
||||
{ field: "serviceId", value: "702" },
|
||||
{ field: "workOrderType", value: "PM" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits service notes modal ops in pm → extraServices → serviceNotes order", () => {
|
||||
expect(
|
||||
expandTablePatchToBoardFieldOps({
|
||||
|
|
|
|||
|
|
@ -783,6 +783,58 @@ describe("useWorkOrderTableMutations type and severity", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("chains clear, severity, type, and new service using each returned row version", async () => {
|
||||
const calls: { field: string; value: string; version: string }[] = [];
|
||||
rowState = { ...BASE_ROW, type: "PM", serviceId: 701, rowVersion: "wo-v1" };
|
||||
patchBoardField.mockImplementation(
|
||||
async (_id: unknown, payload: { field: string; value: string; workOrderVersion: string }) => {
|
||||
calls.push({
|
||||
field: payload.field,
|
||||
value: payload.value,
|
||||
version: payload.workOrderVersion,
|
||||
});
|
||||
rowState = {
|
||||
...rowState,
|
||||
...(payload.field === "serviceId"
|
||||
? { serviceId: payload.value ? Number(payload.value) : null }
|
||||
: {}),
|
||||
...(payload.field === "workOrderType" ? { type: payload.value as "Emergency" } : {}),
|
||||
...(payload.field === "severity" ? { severity: 2 as const } : {}),
|
||||
rowVersion: `wo-v${calls.length + 1}`,
|
||||
};
|
||||
return { ...rowState };
|
||||
},
|
||||
);
|
||||
|
||||
const onPatch = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch,
|
||||
clearPatch: () => undefined,
|
||||
getRow: () => rowState,
|
||||
}),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(42, {
|
||||
type: "Emergency",
|
||||
severity: 2,
|
||||
serviceId: 702,
|
||||
pm: "Emergency Service",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(calls).toHaveLength(4));
|
||||
expect(calls).toEqual([
|
||||
{ field: "serviceId", value: "", version: "wo-v1" },
|
||||
{ field: "severity", value: "2", version: "wo-v2" },
|
||||
{ field: "workOrderType", value: "Emergency", version: "wo-v3" },
|
||||
{ field: "serviceId", value: "702", version: "wo-v4" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies currentState on 409 conflict instead of clearing", async () => {
|
||||
const currentState = { ...BASE_ROW, type: "PM" as const, severity: null };
|
||||
patchBoardField.mockRejectedValue(new BoardPatchConflictError("stale", currentState));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch";
|
||||
|
||||
describe("serviceIdentityPatch", () => {
|
||||
it("keeps an unchanged registry identity out of unrelated detail saves", () => {
|
||||
expect(serviceIdentityPatch(701, "Old snapshot", "QA Test Service", 701)).toEqual({});
|
||||
});
|
||||
|
||||
it("emits the registry identity when selecting a registered service", () => {
|
||||
expect(serviceIdentityPatch(null, "Legacy value", "QA Test Service", 701)).toEqual({
|
||||
pm: "QA Test Service",
|
||||
serviceId: 701,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears a registry identity when the selection is removed", () => {
|
||||
expect(serviceIdentityPatch(701, "QA Test Service", "", null)).toEqual({
|
||||
pm: "",
|
||||
serviceId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps legacy PM edits label-based", () => {
|
||||
expect(serviceIdentityPatch(null, "Legacy value", "Another legacy value", undefined)).toEqual({
|
||||
pm: "Another legacy value",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { mergeAutoSchedulePatch } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { expandTablePatchToBoardFieldOps } from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
|
||||
|
||||
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
|
|
@ -68,6 +69,7 @@ describe("slide-over-draft", () => {
|
|||
expect(SLIDE_OVER_EDIT_KEYS).toContain("technicianAssigned");
|
||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("additionalContacts");
|
||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("avetaRequired");
|
||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("serviceId");
|
||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("pocName");
|
||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("pocPhone");
|
||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("pocNotes");
|
||||
|
|
@ -77,6 +79,20 @@ describe("slide-over-draft", () => {
|
|||
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("tech");
|
||||
});
|
||||
|
||||
it("saves an incompatible type change after clearing its registry service", () => {
|
||||
const baseline = baseRow({ type: "PM", pm: "PM only", serviceId: 701 });
|
||||
const draft = baseRow({ type: "Reactive", severity: 2, pm: "", serviceId: null });
|
||||
const patch = buildSlideOverPatch(draft, baseline);
|
||||
|
||||
expect(isSlideOverDraftDirty(draft, baseline)).toBe(true);
|
||||
expect(patch).toMatchObject({ type: "Reactive", severity: 2, serviceId: null, pm: "" });
|
||||
expect(expandTablePatchToBoardFieldOps(patch)).toEqual([
|
||||
{ field: "severity", value: "2" },
|
||||
{ field: "serviceId", value: "" },
|
||||
{ field: "workOrderType", value: "Reactive" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("tracks vendor via vendorId only (company/tech are display companions)", () => {
|
||||
const row = baseRow();
|
||||
const draft = baseRow({ company: "Other Co", tech: "Pat" });
|
||||
|
|
|
|||
|
|
@ -370,6 +370,42 @@ describe("buildDayGroups", () => {
|
|||
});
|
||||
|
||||
describe("matchesAdvancedFilters", () => {
|
||||
it("matches inactive renamed registry services across the global date range", () => {
|
||||
const row = makeRow({
|
||||
pm: "Historic Service Name",
|
||||
serviceId: 701,
|
||||
scheduledOn: "2024-03-12",
|
||||
});
|
||||
const filters = {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
pmTypes: new Set(["Current Service Name"]),
|
||||
};
|
||||
const serviceHistory = [
|
||||
{
|
||||
id: 701,
|
||||
name: "Current Service Name",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: false,
|
||||
supportedWorkOrderTypes: ["PM" as const],
|
||||
},
|
||||
];
|
||||
|
||||
expect(matchesAdvancedFilters(row, filters, serviceHistory)).toBe(true);
|
||||
expect(row.pm).toBe("Historic Service Name");
|
||||
});
|
||||
|
||||
it("continues matching legacy service labels without a linked registry identity", () => {
|
||||
const filters = {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
pmTypes: new Set(["Legacy Problem"]),
|
||||
};
|
||||
|
||||
expect(matchesAdvancedFilters(makeRow({ pm: "Legacy Problem" }), filters, [])).toBe(true);
|
||||
});
|
||||
|
||||
it("filters by site and type", () => {
|
||||
const weekMonday = mondayOf(todayIso());
|
||||
const row = makeRow({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue