mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 09:13:11 +00:00
Merge remote-tracking branch 'origin/feat/ab/sh-335-wo-site-dialog' into feat/ab/sh-312-site-map-preview
This commit is contained in:
commit
a2b985b14a
21 changed files with 615 additions and 173 deletions
|
|
@ -109,7 +109,11 @@ export function AdvancedSearchFilterSections({
|
|||
/>
|
||||
</AdvSection>
|
||||
|
||||
<AdvSection icon={CheckCircle2} label="Status" count={draft.statuses.size}>
|
||||
<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) => (
|
||||
|
|
@ -122,6 +126,14 @@ export function AdvancedSearchFilterSections({
|
|||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch";
|
||||
import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types";
|
||||
import { resolveLocationId } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
|
||||
import type { useSiteDialogFormFields } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields";
|
||||
import {
|
||||
workOrderPocAfterSiteSave,
|
||||
type useSiteRecordSync,
|
||||
type WorkOrderPoc,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync";
|
||||
import { useUpdateSiteContactInfo } from "@/domain/locations/use-cases/use-update-site-contact-info";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
type UseSiteDialogSaveArgs = {
|
||||
fields: ReturnType<typeof useSiteDialogFormFields>;
|
||||
siteRecord: ReturnType<typeof useSiteRecordSync>;
|
||||
/** The work order's values when the dialog opened. */
|
||||
original: WorkOrderPoc & { locationId: string | number; value: string; sites: LocationOption[] };
|
||||
createMode: boolean;
|
||||
/** The first contact is required before Save (inline create, or editing the site record). */
|
||||
requiresPoc: boolean;
|
||||
onSave: (patch: SitePatch) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The Site dialog's Save: writes changed contacts and notes to the site record first when the
|
||||
* dialog edits it, then patches the work order and closes.
|
||||
*/
|
||||
export function useSiteDialogSave({
|
||||
fields,
|
||||
siteRecord,
|
||||
original,
|
||||
createMode,
|
||||
requiresPoc,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
}: UseSiteDialogSaveArgs) {
|
||||
const updateSite = useUpdateSiteContactInfo();
|
||||
const pocMissing = !fields.pn.trim() || !fields.pp.trim();
|
||||
const canConfirm = !fields.siteMissing && (!requiresPoc || !pocMissing);
|
||||
|
||||
const saveWorkOrder = () => {
|
||||
const poc = workOrderPocAfterSiteSave(
|
||||
siteRecord,
|
||||
original,
|
||||
{ pocName: fields.pn, pocPhone: fields.pp, pocNotes: fields.notes },
|
||||
fields.locId !== resolveLocationId(original.locationId, original.value, original.sites),
|
||||
);
|
||||
onSave(
|
||||
buildSiteDialogPatch({
|
||||
code: fields.code,
|
||||
locId: fields.locId,
|
||||
selected: fields.selected,
|
||||
...poc,
|
||||
extraContacts: fields.extraContacts,
|
||||
contactsDirty: fields.contactsDirty,
|
||||
baselineHadContacts: fields.baselineHadContacts,
|
||||
followsSiteRecord: siteRecord.synced,
|
||||
}),
|
||||
);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const attemptSave = () => {
|
||||
if (requiresPoc && !canConfirm) {
|
||||
fields.setShowErrors(true);
|
||||
return;
|
||||
}
|
||||
if (!siteRecord.siteChanged) {
|
||||
saveWorkOrder();
|
||||
return;
|
||||
}
|
||||
updateSite.mutate({ id: fields.locId, ...siteRecord.request }, { onSuccess: saveWorkOrder });
|
||||
};
|
||||
|
||||
return {
|
||||
attemptSave,
|
||||
// Save is actionable only once something changed; inline create keeps its confirm step.
|
||||
saveDisabled: updateSite.isPending || siteRecord.loading || (!createMode && !fields.dirty),
|
||||
saving: updateSite.isPending,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,15 +1,11 @@
|
|||
import { useEffect, useMemo } from "react";
|
||||
import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch";
|
||||
import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types";
|
||||
import { useSiteDialogFormFields } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields";
|
||||
import { resolveLocationId } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
|
||||
import {
|
||||
useSiteRecordSync,
|
||||
workOrderPocAfterSiteSave,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync";
|
||||
import { useUpdateSiteContactInfo } from "@/domain/locations/use-cases/use-update-site-contact-info";
|
||||
import { useSiteDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-save";
|
||||
import { useSiteRecordSync } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync";
|
||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderFrozenSite } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
|
@ -32,6 +28,20 @@ type UseSiteDialogStateArgs = {
|
|||
onSave: (patch: SitePatch) => void;
|
||||
};
|
||||
|
||||
/** A completed work order's frozen snapshot, shaped like the live site detail. */
|
||||
function frozenSiteDetail(frozenSite: WorkOrderFrozenSite): Location {
|
||||
return {
|
||||
name: frozenSite.label,
|
||||
address: frozenSite.address,
|
||||
city: frozenSite.city,
|
||||
state: frozenSite.state,
|
||||
zipCode: frozenSite.zip,
|
||||
phone: frozenSite.phone,
|
||||
contact: undefined,
|
||||
contactEmail: frozenSite.email,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSiteDialogState({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -58,96 +68,51 @@ export function useSiteDialogState({
|
|||
sites,
|
||||
createMode,
|
||||
});
|
||||
const { locId, pocFilledFor, pn, pp, setPn, setPp, setPocFilledFor, siteMissing } = fields;
|
||||
const {
|
||||
data: liveLocationDetail,
|
||||
isLoading: liveLocationDetailLoading,
|
||||
isError: liveLocationDetailError,
|
||||
} = useLocationDetail(frozenSite == null && open && locId ? locId : undefined);
|
||||
const { locId, pocFilledFor, setPn, setPp, setPocFilledFor } = fields;
|
||||
const live = frozenSite == null;
|
||||
const liveDetail = useLocationDetail(live && open && locId ? locId : undefined);
|
||||
const frozenLocationDetail = useMemo(
|
||||
() =>
|
||||
frozenSite == null
|
||||
? undefined
|
||||
: {
|
||||
name: frozenSite.label,
|
||||
address: frozenSite.address,
|
||||
city: frozenSite.city,
|
||||
state: frozenSite.state,
|
||||
zipCode: frozenSite.zip,
|
||||
phone: frozenSite.phone,
|
||||
contact: undefined,
|
||||
contactEmail: frozenSite.email,
|
||||
},
|
||||
() => (frozenSite == null ? undefined : frozenSiteDetail(frozenSite)),
|
||||
[frozenSite],
|
||||
);
|
||||
const locationDetail = frozenLocationDetail ?? liveLocationDetail;
|
||||
const locationDetailLoading = frozenSite == null && liveLocationDetailLoading;
|
||||
const locationDetailError = frozenSite == null && liveLocationDetailError;
|
||||
const locationDetail = frozenLocationDetail ?? liveDetail.data;
|
||||
const locationDetailLoading = live && liveDetail.isLoading;
|
||||
const locationDetailError = live && liveDetail.isError;
|
||||
|
||||
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
|
||||
// An existing, editable work order edits the site record itself (contacts and notes).
|
||||
const editsSiteRecord = !createMode && !viewOnly && frozenSite == null;
|
||||
const editsSiteRecord = !createMode && !viewOnly && live;
|
||||
const siteRecord = useSiteRecordSync({
|
||||
enabled: editsSiteRecord,
|
||||
open,
|
||||
locId,
|
||||
locationDetail: liveLocationDetail,
|
||||
locationDetailError: liveLocationDetailError,
|
||||
locationDetail: liveDetail.data,
|
||||
locationDetailFetching: liveDetail.isFetching,
|
||||
locationDetailError: liveDetail.isError,
|
||||
fields,
|
||||
});
|
||||
// The site failed to load (or the user typed before it did), so Save writes this work order only.
|
||||
const siteRecordUnavailable =
|
||||
editsSiteRecord && Boolean(locId) && !siteRecord.synced && !siteRecord.loading;
|
||||
const updateSite = useUpdateSiteContactInfo();
|
||||
const pocMissing = !pn.trim() || !pp.trim();
|
||||
const requiresPoc = createMode || editsSiteRecord;
|
||||
const canConfirm = !siteMissing && (!requiresPoc || !pocMissing);
|
||||
const save = useSiteDialogSave({
|
||||
fields,
|
||||
siteRecord,
|
||||
original: { pocName, pocPhone, pocNotes, locationId, value, sites },
|
||||
createMode,
|
||||
requiresPoc,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (frozenSite != null || !open || !locId || !locationDetail || pocFilledFor === locId) {
|
||||
if (!live || !open || !locId || !locationDetail || pocFilledFor === locId) {
|
||||
return;
|
||||
}
|
||||
setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
|
||||
setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
|
||||
setPocFilledFor(locId);
|
||||
}, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, frozenSite]);
|
||||
|
||||
const saveWorkOrder = () => {
|
||||
const poc = workOrderPocAfterSiteSave(
|
||||
siteRecord,
|
||||
{ pocName, pocPhone, pocNotes },
|
||||
{ pocName: fields.pn, pocPhone: fields.pp, pocNotes: fields.notes },
|
||||
fields.locId !== resolveLocationId(locationId, value, sites),
|
||||
);
|
||||
onSave(
|
||||
buildSiteDialogPatch({
|
||||
code: fields.code,
|
||||
locId: fields.locId,
|
||||
selected: fields.selected,
|
||||
...poc,
|
||||
extraContacts: fields.extraContacts,
|
||||
contactsDirty: fields.contactsDirty,
|
||||
baselineHadContacts: fields.baselineHadContacts,
|
||||
followsSiteRecord: siteRecord.synced,
|
||||
}),
|
||||
);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const attemptSave = () => {
|
||||
if (requiresPoc && !canConfirm) {
|
||||
fields.setShowErrors(true);
|
||||
return;
|
||||
}
|
||||
if (!siteRecord.siteChanged) {
|
||||
saveWorkOrder();
|
||||
return;
|
||||
}
|
||||
updateSite.mutate({ id: locId, ...siteRecord.request }, { onSuccess: saveWorkOrder });
|
||||
};
|
||||
|
||||
// Save is actionable only once something changed; inline create keeps its confirm step.
|
||||
const saveDisabled = updateSite.isPending || siteRecord.loading || (!createMode && !fields.dirty);
|
||||
}, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, live]);
|
||||
|
||||
return {
|
||||
locId: fields.locId,
|
||||
|
|
@ -164,9 +129,9 @@ export function useSiteDialogState({
|
|||
showErrors: fields.showErrors,
|
||||
siteMissing: fields.siteMissing,
|
||||
handlePick: fields.handlePick,
|
||||
attemptSave,
|
||||
saveDisabled,
|
||||
saving: updateSite.isPending,
|
||||
attemptSave: save.attemptSave,
|
||||
saveDisabled: save.saveDisabled,
|
||||
saving: save.saving,
|
||||
siteRecordLoading: siteRecord.loading,
|
||||
editsSiteRecord,
|
||||
siteRecordUnavailable,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ type UseSiteRecordSyncArgs = {
|
|||
open: boolean;
|
||||
locId: string;
|
||||
locationDetail: Location | undefined;
|
||||
/** A request for the site is in flight; cached data may predate the latest save. */
|
||||
locationDetailFetching: boolean;
|
||||
locationDetailError: boolean;
|
||||
fields: SiteRecordFields;
|
||||
};
|
||||
|
|
@ -30,6 +32,10 @@ export type WorkOrderPoc = { pocName: string; pocPhone: string; pocNotes: string
|
|||
|
||||
type SiteRecordEdits = { primary: boolean; notes: boolean };
|
||||
|
||||
function isCompleteContact(contact: WorkOrderAdditionalContact): boolean {
|
||||
return contact.name.trim() !== "" && contact.phone.trim() !== "";
|
||||
}
|
||||
|
||||
function toRequest(
|
||||
primaryId: number | undefined,
|
||||
fields: Pick<SiteRecordFields, "pn" | "pp" | "notes" | "extraContacts">,
|
||||
|
|
@ -37,7 +43,8 @@ function toRequest(
|
|||
return {
|
||||
contacts: [
|
||||
{ ...(primaryId === undefined ? {} : { id: primaryId }), name: fields.pn, phone: fields.pp },
|
||||
...fields.extraContacts.map((contact) => ({
|
||||
// A half-filled extra is dropped here, the same as the work order's own copy drops it.
|
||||
...fields.extraContacts.filter(isCompleteContact).map((contact) => ({
|
||||
...(contact.siteContactId === undefined ? {} : { id: contact.siteContactId }),
|
||||
name: contact.name,
|
||||
phone: contact.phone,
|
||||
|
|
@ -92,6 +99,22 @@ export function workOrderPocAfterSiteSave(
|
|||
};
|
||||
}
|
||||
|
||||
/** The site record's contacts and notes, in the dialog's field shape. */
|
||||
function siteRecordValues(location: Location) {
|
||||
const [main, ...others] = location.contacts ?? [];
|
||||
return {
|
||||
primaryId: main?.id,
|
||||
pn: main?.name ?? "",
|
||||
pp: main?.phone ?? "",
|
||||
notes: location.notes ?? "",
|
||||
extraContacts: others.map((contact) => ({
|
||||
name: contact.name,
|
||||
phone: contact.phone,
|
||||
...(contact.id === undefined ? {} : { siteContactId: contact.id }),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the selected site's contacts and notes into the Site dialog and reports what the user
|
||||
* changed, so Save can write them back to the site record.
|
||||
|
|
@ -101,6 +124,7 @@ export function useSiteRecordSync({
|
|||
open,
|
||||
locId,
|
||||
locationDetail,
|
||||
locationDetailFetching,
|
||||
locationDetailError,
|
||||
fields,
|
||||
}: UseSiteRecordSyncArgs) {
|
||||
|
|
@ -109,43 +133,34 @@ export function useSiteRecordSync({
|
|||
const [baseline, setBaseline] = useState<SiteContactInfoInput | null>(null);
|
||||
const { userEdited, resetVersion, setPn, setPp, setNotes, setExtraContacts } = fields;
|
||||
|
||||
// Picking another site clears its contacts, so coming back to a site must load it again.
|
||||
useEffect(() => {
|
||||
setSyncedFor("");
|
||||
}, [open, resetVersion]);
|
||||
}, [open, resetVersion, locId]);
|
||||
|
||||
useEffect(() => {
|
||||
const detailMatches = locationDetail !== undefined && String(locationDetail.id) === locId;
|
||||
// Cached site data can predate a save made since, so only a settled request is synced.
|
||||
const detailCurrent = detailMatches && !locationDetailFetching;
|
||||
// Input typed while the site was unavailable (a failed load) is never replaced by a late
|
||||
// response: the dialog stays on the work order's values and saves them to the work order only.
|
||||
if (!enabled || !open || !locId || !detailMatches || syncedFor === locId || userEdited) {
|
||||
if (!enabled || !open || !locId || !detailCurrent || syncedFor === locId || userEdited) {
|
||||
return;
|
||||
}
|
||||
const [main, ...others] = locationDetail.contacts ?? [];
|
||||
const extras = others.map((contact) => ({
|
||||
name: contact.name,
|
||||
phone: contact.phone,
|
||||
...(contact.id === undefined ? {} : { siteContactId: contact.id }),
|
||||
}));
|
||||
const notes = locationDetail.notes ?? "";
|
||||
setPn(main?.name ?? "");
|
||||
setPp(main?.phone ?? "");
|
||||
setExtraContacts(extras);
|
||||
setNotes(notes);
|
||||
setPrimaryId(main?.id);
|
||||
setBaseline(
|
||||
toRequest(main?.id, {
|
||||
pn: main?.name ?? "",
|
||||
pp: main?.phone ?? "",
|
||||
notes,
|
||||
extraContacts: extras,
|
||||
}),
|
||||
);
|
||||
const site = siteRecordValues(locationDetail);
|
||||
setPn(site.pn);
|
||||
setPp(site.pp);
|
||||
setExtraContacts(site.extraContacts);
|
||||
setNotes(site.notes);
|
||||
setPrimaryId(site.primaryId);
|
||||
setBaseline(toRequest(site.primaryId, site));
|
||||
setSyncedFor(locId);
|
||||
}, [
|
||||
enabled,
|
||||
open,
|
||||
locId,
|
||||
locationDetail,
|
||||
locationDetailFetching,
|
||||
syncedFor,
|
||||
userEdited,
|
||||
setPn,
|
||||
|
|
|
|||
|
|
@ -20,10 +20,13 @@ const SERVICE_WORK_ORDER_TYPE_API = {
|
|||
Reactive: 6,
|
||||
} as const;
|
||||
|
||||
const TYPE_TO_API: Record<Exclude<WOType, "Overdue">, number> = {
|
||||
// The registry is keyed on PM, Reactive and Emergency; Overdue is PM work the
|
||||
// client never confirmed, so it lists the PM catalog.
|
||||
const TYPE_TO_API: Record<WOType, number> = {
|
||||
PM: WORK_ORDER_TYPE_API.PM,
|
||||
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
||||
Emergency: WORK_ORDER_TYPE_API.Emergency,
|
||||
Overdue: WORK_ORDER_TYPE_API.PM,
|
||||
};
|
||||
|
||||
function toApiInput(input: ServiceInput) {
|
||||
|
|
@ -51,7 +54,6 @@ export const servicesApi = {
|
|||
},
|
||||
|
||||
getActiveForWorkOrderType: async (workOrderType: WOType): Promise<Service[]> => {
|
||||
if (workOrderType === "Overdue") return [];
|
||||
const data = await apiGet<unknown>(API_PATHS.services.list, {
|
||||
searchParams: { isActive: true, workOrderType: TYPE_TO_API[workOrderType] },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ export function useServicesForWorkOrderType(
|
|||
return useQuery({
|
||||
queryKey: queryKeys.services.byWorkOrderType(effectiveType),
|
||||
queryFn: () => servicesApi.getActiveForWorkOrderType(effectiveType),
|
||||
enabled: effectiveType !== "Overdue",
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
|
||||
import { typeNeedsSeverity } from "@/domain/work-orders/types/work-order-severity";
|
||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
|
||||
|
||||
|
|
@ -64,7 +65,7 @@ export function assignWizardServicePocFields(
|
|||
payload.vendorNotes = vendorNotes;
|
||||
}
|
||||
|
||||
if (draft.severity != null) {
|
||||
if (draft.severity != null && typeNeedsSeverity(draft.type)) {
|
||||
payload.severity = draft.severity;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const WO_TYPE_TO_API: Record<WOType, WorkOrderTypeApi> = {
|
|||
|
||||
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
||||
|
||||
Overdue: WORK_ORDER_TYPE_API.Other,
|
||||
Overdue: WORK_ORDER_TYPE_API.Overdue,
|
||||
};
|
||||
|
||||
/** Leading token of a location label, e.g. `BK5` from `BK5 · Dallas` or `CHI-1042` from `CHI-1042 — Chicago`. */
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ export interface WorkOrderAdvancedFilters {
|
|||
dispatchers: Set<string>;
|
||||
regions: Set<string>;
|
||||
statuses: Set<WizardWOStatus>;
|
||||
/** Past Due overlay (any type); narrows the other filters, independent of the Overdue type. */
|
||||
pastDue: boolean;
|
||||
rescheduled: boolean;
|
||||
carriedOver: boolean;
|
||||
addOn: boolean;
|
||||
|
|
@ -61,6 +63,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
|||
dispatchers: new Set(),
|
||||
regions: new Set(),
|
||||
statuses: new Set(),
|
||||
pastDue: false,
|
||||
rescheduled: false,
|
||||
carriedOver: false,
|
||||
addOn: false,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const WORK_ORDER_TYPE_API = {
|
|||
Inspection: 5,
|
||||
Reactive: 6,
|
||||
AddOn: 7,
|
||||
Overdue: 8,
|
||||
Other: 99,
|
||||
} as const;
|
||||
|
||||
|
|
@ -64,6 +65,7 @@ export const WORK_ORDER_TYPE_API_TO_FE: Record<number, WOType | ""> = {
|
|||
[WORK_ORDER_TYPE_API.PM]: "PM",
|
||||
[WORK_ORDER_TYPE_API.Reactive]: "Reactive",
|
||||
[WORK_ORDER_TYPE_API.Emergency]: "Emergency",
|
||||
[WORK_ORDER_TYPE_API.Overdue]: "Overdue",
|
||||
};
|
||||
|
||||
export const LIFECYCLE_STATUS_API_TO_FE: Record<number, WizardWOStatus | "Canceled"> = {
|
||||
|
|
|
|||
|
|
@ -192,6 +192,15 @@ export function appendStatusPmVendorDocChips(
|
|||
clear: (current) => ({ ...current, statuses: new Set() }),
|
||||
});
|
||||
|
||||
if (filters.pastDue) {
|
||||
chips.push({
|
||||
key: "pastdue",
|
||||
group: "Status",
|
||||
value: "Past Due",
|
||||
clear: (current) => ({ ...current, pastDue: false }),
|
||||
});
|
||||
}
|
||||
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "pm",
|
||||
|
|
|
|||
|
|
@ -45,12 +45,10 @@ const matchesDateRange: AdvancedFilterPredicate = (row, filters) => {
|
|||
const matchesSite: AdvancedFilterPredicate = (row, filters) =>
|
||||
!filters.sites.size || filters.sites.has(row.site);
|
||||
|
||||
const matchesType: AdvancedFilterPredicate = (row, filters) => {
|
||||
if (!filters.types.size) return true;
|
||||
const overdueMatch = filters.types.has("Overdue") && row.isPastDue;
|
||||
const typeMatch = filters.types.has(row.type as never);
|
||||
return overdueMatch || typeMatch;
|
||||
};
|
||||
const matchesType: AdvancedFilterPredicate = (row, filters) =>
|
||||
!filters.types.size || filters.types.has(row.type as never);
|
||||
|
||||
const matchesPastDue: AdvancedFilterPredicate = (row, filters) => !filters.pastDue || row.isPastDue;
|
||||
|
||||
const matchesDispatcher: AdvancedFilterPredicate = (row, filters) => {
|
||||
if (!filters.dispatchers.size) return true;
|
||||
|
|
@ -95,6 +93,7 @@ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [
|
|||
matchesType,
|
||||
matchesDispatcher,
|
||||
matchesStatus,
|
||||
matchesPastDue,
|
||||
matchesPmType,
|
||||
matchesVendorTech,
|
||||
matchesDoc,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte
|
|||
upliftStatuses: new Set(filters.upliftStatuses ?? []),
|
||||
workOrderIds: new Set(filters.workOrderIds ?? []),
|
||||
unscheduledOnly: Boolean(filters.unscheduledOnly),
|
||||
pastDue: Boolean(filters.pastDue),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -47,6 +48,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool
|
|||
Boolean(filters.dispatchers?.size),
|
||||
Boolean(filters.regions?.size),
|
||||
Boolean(filters.statuses?.size),
|
||||
Boolean(filters.pastDue),
|
||||
filters.rescheduled,
|
||||
filters.carriedOver,
|
||||
filters.addOn,
|
||||
|
|
|
|||
|
|
@ -42,30 +42,19 @@ function mapDispatchers(ids: Iterable<string>): string[] | undefined {
|
|||
return list.length > 0 ? list : undefined;
|
||||
}
|
||||
|
||||
/** Maps UI type filters to API `types` + separate `overdue` flag. Overdue is never sent as types=99. */
|
||||
function mapWoTypeFilters(types: Iterable<WOType | string>): {
|
||||
types?: number[];
|
||||
overdue?: boolean;
|
||||
} {
|
||||
/** Maps UI type filters to API `types`. The Overdue type is sent as its own enum; past due uses `overdue`. */
|
||||
function mapWoTypeFilters(types: Iterable<WOType | string>): number[] | undefined {
|
||||
const list: number[] = [];
|
||||
let overdue = false;
|
||||
for (const type of types) {
|
||||
if (type === "All") {
|
||||
continue;
|
||||
}
|
||||
if (type === "Overdue") {
|
||||
overdue = true;
|
||||
continue;
|
||||
}
|
||||
const api = mapWizardTypeToApi(type as WOType);
|
||||
if (api != null) {
|
||||
list.push(api);
|
||||
}
|
||||
}
|
||||
return {
|
||||
types: list.length > 0 ? list : undefined,
|
||||
overdue: overdue || undefined,
|
||||
};
|
||||
return list.length > 0 ? list : undefined;
|
||||
}
|
||||
|
||||
function mapStatuses(statuses: Iterable<WizardWOStatus | string>): number[] | undefined {
|
||||
|
|
@ -147,14 +136,11 @@ export function toBoardQueryParams(
|
|||
search: string,
|
||||
): WorkOrderBoardQueryParams {
|
||||
const [, weekTo] = weekRange(bar.weekMonday);
|
||||
const typeFilters = bar.types.size > 0 ? mapWoTypeFilters(bar.types) : {};
|
||||
|
||||
return {
|
||||
weekStart: bar.weekMonday,
|
||||
weekEnd: weekTo,
|
||||
dispatchers: mapDispatchers(bar.dispatcherIds),
|
||||
types: typeFilters.types,
|
||||
overdue: typeFilters.overdue,
|
||||
types: mapWoTypeFilters(bar.types),
|
||||
search: search.trim().length >= 2 ? search.trim() : undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -211,7 +197,6 @@ export function toBoardSearchQueryParams(input: {
|
|||
BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||
);
|
||||
|
||||
const typeFilters = mapWoTypeFilters(filters.types);
|
||||
const trimmedSearch = input.search?.trim();
|
||||
const search = trimmedSearch && trimmedSearch.length >= 2 ? trimmedSearch : undefined;
|
||||
|
||||
|
|
@ -224,8 +209,8 @@ export function toBoardSearchQueryParams(input: {
|
|||
dateTo: date.dateTo,
|
||||
includeDateless: date.includeDateless,
|
||||
sites: filters.sites.size > 0 ? [...filters.sites] : undefined,
|
||||
types: typeFilters.types,
|
||||
overdue: typeFilters.overdue,
|
||||
types: mapWoTypeFilters(filters.types),
|
||||
overdue: filters.pastDue || undefined,
|
||||
dispatchers: mapDispatchers(filters.dispatchers),
|
||||
regions: filters.regions.size > 0 ? [...filters.regions] : undefined,
|
||||
statuses: mapStatuses(filters.statuses),
|
||||
|
|
@ -261,10 +246,6 @@ export function buildBoardSearchParamsRecord(
|
|||
return out;
|
||||
}
|
||||
|
||||
export function woTypeFilterIsOverdue(type: string): boolean {
|
||||
return type === "Overdue";
|
||||
}
|
||||
|
||||
/** Week-independent GET /board/search params for the pinned Unassigned queue. */
|
||||
export function toUnassignedQueueQueryParams(
|
||||
page = 0,
|
||||
|
|
|
|||
|
|
@ -65,12 +65,7 @@ function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean {
|
|||
}
|
||||
|
||||
function rowMatchesBarTypes(row: WorkOrderTableRow, types: Set<WOType>): boolean {
|
||||
if (types.size === 0) {
|
||||
return true;
|
||||
}
|
||||
const overdueMatch = types.has("Overdue") && row.isPastDue;
|
||||
const typeMatch = types.has(row.type as WOType);
|
||||
return overdueMatch || typeMatch;
|
||||
return types.size === 0 || types.has(row.type as WOType);
|
||||
}
|
||||
|
||||
export function filterByBarAndSearch(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
|
||||
import { AdvancedSearchFilterSections } from "@/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections";
|
||||
import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
|
||||
import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
|
||||
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
|
||||
import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import {
|
||||
EMPTY_ADVANCED_FILTERS,
|
||||
type WorkOrderAdvancedFilters,
|
||||
} from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
canContinueWizardStep1,
|
||||
EMPTY_WIZARD_DRAFT,
|
||||
type WorkOrderWizardDraft,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { buildAdvancedFilterChips } from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||
import { getClosabilityGaps } from "@/domain/work-orders/utils/wo-closability";
|
||||
|
||||
const USERS = [{ id: "sarah", name: "Sarah Chen" }];
|
||||
|
||||
describe("Overdue work order type", () => {
|
||||
it("offers Overdue in the Create WO wizard and never asks for a severity", () => {
|
||||
const onPatch = vi.fn();
|
||||
const { rerender } = render(
|
||||
<WizardStepTypeSchedule
|
||||
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM" }}
|
||||
users={USERS}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={onPatch}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /No client response — due date passed/ }));
|
||||
expect(onPatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "Overdue", severity: null }),
|
||||
);
|
||||
|
||||
const overdueDraft: WorkOrderWizardDraft = { ...EMPTY_WIZARD_DRAFT, type: "Overdue" };
|
||||
rerender(
|
||||
<WizardStepTypeSchedule
|
||||
draft={overdueDraft}
|
||||
users={USERS}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={onPatch}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText(/Severity \(from APM\)/)).not.toBeInTheDocument();
|
||||
expect(canContinueWizardStep1(overdueDraft)).toBe(true);
|
||||
});
|
||||
|
||||
it("creates the work order with the Overdue enum and no severity", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "Overdue",
|
||||
severity: 2,
|
||||
locationId: "1",
|
||||
siteCode: "BK5",
|
||||
});
|
||||
|
||||
expect(payload.workOrderType).toBe(WORK_ORDER_TYPE_API.Overdue);
|
||||
expect(payload.severity ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it("corrects a type inline to Overdue without opening the severity dialog", async () => {
|
||||
const onSave = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<TypeCell value="Reactive" severity={3} onSave={onSave} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Type of WO, Reactive" }));
|
||||
await user.click(screen.getByRole("button", { name: /^Overdue$/ }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith("Overdue", null);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reads the Overdue enum back from the board as the Overdue type", () => {
|
||||
const row = mapWorkOrderTableRow({ id: 1, workOrderType: WORK_ORDER_TYPE_API.Overdue });
|
||||
expect(row.type).toBe("Overdue");
|
||||
});
|
||||
|
||||
it("closes with the completion document, not photos", () => {
|
||||
const base = {
|
||||
id: "1",
|
||||
dispatcherId: "sarah",
|
||||
dispatcherName: "Sarah Chen",
|
||||
company: "Gateway Plumbing",
|
||||
vendorId: "",
|
||||
tech: "",
|
||||
completedDate: "2026-09-24",
|
||||
woNumber: "24971",
|
||||
pm: "Backflow Preventers",
|
||||
mediaCount: 0,
|
||||
};
|
||||
expect(
|
||||
getClosabilityGaps({ ...base, type: "Overdue" as const, docStatus: "No" as const }),
|
||||
).toEqual(["Completion document"]);
|
||||
expect(
|
||||
getClosabilityGaps({ ...base, type: "Overdue" as const, docStatus: "Yes" as const }),
|
||||
).toEqual([]);
|
||||
|
||||
const row = {
|
||||
id: "1",
|
||||
type: "Overdue",
|
||||
pm: "Backflow Preventers",
|
||||
docStatus: "No",
|
||||
rowVersion: "1",
|
||||
};
|
||||
render(
|
||||
<SlideOverCompletionTab
|
||||
row={row as WorkOrderTableRow}
|
||||
isReadOnly={false}
|
||||
media={[]}
|
||||
onOpenCompletionDoc={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Completion Document")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /open completion document/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("keeps a separate Past Due status toggle in the advanced filters", () => {
|
||||
let draft: WorkOrderAdvancedFilters = { ...EMPTY_ADVANCED_FILTERS };
|
||||
const setDraft = vi.fn(
|
||||
(update: (prev: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters) => {
|
||||
draft = update(draft);
|
||||
},
|
||||
);
|
||||
render(
|
||||
<AdvancedSearchFilterSections
|
||||
draft={draft}
|
||||
setDraft={setDraft as never}
|
||||
toggleIn={vi.fn()}
|
||||
setMany={vi.fn()}
|
||||
sites={[]}
|
||||
users={[]}
|
||||
pmTypes={[]}
|
||||
vendorTechs={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Status/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Past Due/ }));
|
||||
|
||||
expect(draft.pastDue).toBe(true);
|
||||
expect(draft.types.size).toBe(0);
|
||||
expect(buildAdvancedFilterChips(draft)).toContainEqual(
|
||||
expect.objectContaining({ group: "Status", value: "Past Due" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SiteDialog } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const getById = vi.fn();
|
||||
const updateContactInfo = vi.fn();
|
||||
|
||||
vi.mock("@/domain/locations/api/locations-api", () => ({
|
||||
locationsApi: {
|
||||
getById: (...args: unknown[]) => getById(...args),
|
||||
updateContactInfo: (...args: unknown[]) => updateContactInfo(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const dallas: Location = {
|
||||
id: 1,
|
||||
name: "DAL1",
|
||||
address: "3811 Distribution Dr",
|
||||
city: "Dallas",
|
||||
state: "TX",
|
||||
zipCode: "75201",
|
||||
sitePhone: "(214) 555-0100",
|
||||
notes: "Gate code 1234",
|
||||
contacts: [
|
||||
{ id: 31, name: "Jane", phone: "(421) 433-0032" },
|
||||
{ id: 32, name: "Bob", phone: "(421) 433-0033" },
|
||||
],
|
||||
};
|
||||
|
||||
const DALLAS_CONTACTS = [
|
||||
{ id: 31, name: "Jane", phone: "(421) 433-0032" },
|
||||
{ id: 32, name: "Bob", phone: "(421) 433-0033" },
|
||||
];
|
||||
|
||||
const SITES = [
|
||||
{ id: "1", name: "DAL1" },
|
||||
{ id: "2", name: "CHI2" },
|
||||
];
|
||||
|
||||
const SITE_RECORD_COPY =
|
||||
"Contacts and notes are saved to the site record and apply to all its work orders.";
|
||||
const SITE_UNAVAILABLE_COPY = "Site record unavailable — changes apply to this work order only.";
|
||||
|
||||
function renderDialog(queryClient?: QueryClient) {
|
||||
const onSave = vi.fn();
|
||||
renderWithProviders(
|
||||
<SiteDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
value="DAL1"
|
||||
locationId="1"
|
||||
pocName="Someone Else"
|
||||
pocPhone="(000) 000-0000"
|
||||
pocNotes="Old work order note"
|
||||
sites={SITES}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
{ withAuth: false, ...(queryClient === undefined ? {} : { queryClient }) },
|
||||
);
|
||||
return { onSave };
|
||||
}
|
||||
|
||||
/** The main contact's field; additional contact rows reuse the same placeholders after it. */
|
||||
function primaryField(placeholder: string): HTMLElement {
|
||||
return screen.getAllByPlaceholderText(placeholder)[0];
|
||||
}
|
||||
|
||||
/** Opens the Site picker and chooses a site by its label. */
|
||||
function pickSite(label: string) {
|
||||
const trigger = document.querySelector<HTMLElement>('[aria-haspopup="listbox"]');
|
||||
if (trigger === null) throw new Error("expected the Site picker");
|
||||
fireEvent.click(trigger);
|
||||
fireEvent.click(screen.getByRole("button", { name: label }));
|
||||
}
|
||||
|
||||
function editNotesAndSave(notes: string) {
|
||||
fireEvent.change(primaryField("Notes…"), { target: { value: notes } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||
}
|
||||
|
||||
describe("Work order Site dialog keeps the site record in sync", () => {
|
||||
beforeEach(() => {
|
||||
getById.mockReset();
|
||||
updateContactInfo.mockReset();
|
||||
updateContactInfo.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("re-syncs a site's extra contacts after switching away and back while the other site loads", async () => {
|
||||
getById.mockImplementation((id: string) =>
|
||||
String(id) === "1" ? Promise.resolve(dallas) : new Promise<Location>(() => {}),
|
||||
);
|
||||
renderDialog();
|
||||
expect(await screen.findByDisplayValue("Bob")).toBeInTheDocument();
|
||||
|
||||
pickSite("CHI2");
|
||||
await waitFor(() => expect(getById).toHaveBeenCalledWith("2"));
|
||||
expect(screen.queryByDisplayValue("Bob")).not.toBeInTheDocument();
|
||||
pickSite("DAL1");
|
||||
|
||||
expect(await screen.findByDisplayValue("Bob")).toBeEnabled();
|
||||
editNotesAndSave("Gate code 9999");
|
||||
|
||||
await waitFor(() => expect(updateContactInfo).toHaveBeenCalledTimes(1));
|
||||
expect(updateContactInfo).toHaveBeenCalledWith("1", {
|
||||
contacts: DALLAS_CONTACTS,
|
||||
notes: "Gate code 9999",
|
||||
});
|
||||
});
|
||||
|
||||
it("re-syncs a site's extra contacts after switching away and back when the other site failed to load", async () => {
|
||||
getById.mockImplementation((id: string) =>
|
||||
String(id) === "1" ? Promise.resolve(dallas) : Promise.reject(new Error("Network error")),
|
||||
);
|
||||
renderDialog();
|
||||
expect(await screen.findByDisplayValue("Bob")).toBeInTheDocument();
|
||||
|
||||
pickSite("CHI2");
|
||||
expect(await screen.findByText(SITE_UNAVAILABLE_COPY)).toBeInTheDocument();
|
||||
pickSite("DAL1");
|
||||
|
||||
expect(await screen.findByDisplayValue("Bob")).toBeEnabled();
|
||||
expect(screen.getByText(SITE_RECORD_COPY)).toBeInTheDocument();
|
||||
editNotesAndSave("Gate code 9999");
|
||||
|
||||
await waitFor(() => expect(updateContactInfo).toHaveBeenCalledTimes(1));
|
||||
expect(updateContactInfo).toHaveBeenCalledWith("1", {
|
||||
contacts: DALLAS_CONTACTS,
|
||||
notes: "Gate code 9999",
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for the refetch instead of syncing a cached site record, so a save never reverts it", async () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
// What the cache still holds from before an earlier Save changed the site.
|
||||
queryClient.setQueryData(queryKeys.locations.detail("1"), dallas);
|
||||
const saved: Location = {
|
||||
...dallas,
|
||||
notes: "Saved gate code",
|
||||
contacts: [
|
||||
{ id: 31, name: "Jane", phone: "(421) 433-9999" },
|
||||
{ id: 32, name: "Bob", phone: "(421) 433-0033" },
|
||||
],
|
||||
};
|
||||
let resolveSite: (site: Location) => void = () => {};
|
||||
getById.mockReturnValue(
|
||||
new Promise<Location>((resolve) => {
|
||||
resolveSite = resolve;
|
||||
}),
|
||||
);
|
||||
renderDialog(queryClient);
|
||||
|
||||
await waitFor(() => expect(getById).toHaveBeenCalled());
|
||||
expect(primaryField("POC phone")).toBeDisabled();
|
||||
expect(screen.queryByDisplayValue("(421) 433-0032")).not.toBeInTheDocument();
|
||||
resolveSite(saved);
|
||||
|
||||
expect(await screen.findByDisplayValue("(421) 433-9999")).toBeEnabled();
|
||||
expect(primaryField("Notes…")).toHaveValue("Saved gate code");
|
||||
editNotesAndSave("Gate code 9999");
|
||||
|
||||
await waitFor(() => expect(updateContactInfo).toHaveBeenCalledTimes(1));
|
||||
expect(updateContactInfo).toHaveBeenCalledWith("1", {
|
||||
contacts: saved.contacts,
|
||||
notes: "Gate code 9999",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a half-filled extra contact off the site record, as the work order does", async () => {
|
||||
getById.mockResolvedValue(dallas);
|
||||
const { onSave } = renderDialog();
|
||||
await screen.findByDisplayValue("Bob");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Add point of contact/ }));
|
||||
const names = screen.getAllByPlaceholderText("POC name");
|
||||
fireEvent.change(names[names.length - 1], { target: { value: "Name Only" } });
|
||||
editNotesAndSave("Gate code 9999");
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
expect(updateContactInfo).toHaveBeenCalledWith("1", {
|
||||
contacts: DALLAS_CONTACTS,
|
||||
notes: "Gate code 9999",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -78,11 +78,14 @@ describe("servicesApi", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("does not request services for the unsupported Overdue type", async () => {
|
||||
const services = await servicesApi.getActiveForWorkOrderType("Overdue");
|
||||
it("lists the PM catalog for the Overdue type", async () => {
|
||||
vi.mocked(apiGet).mockResolvedValue([]);
|
||||
|
||||
expect(apiGet).not.toHaveBeenCalled();
|
||||
expect(services).toEqual([]);
|
||||
await servicesApi.getActiveForWorkOrderType("Overdue");
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith(API_PATHS.services.list, {
|
||||
searchParams: { isActive: true, workOrderType: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("creates, updates, and deactivates through the registry routes", async () => {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ describe("mapWizardTypeToApi", () => {
|
|||
|
||||
expect(mapWizardTypeToApi("Reactive")).toBe(WORK_ORDER_TYPE_API.Reactive);
|
||||
|
||||
expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Other);
|
||||
expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Overdue);
|
||||
|
||||
expect(WORK_ORDER_TYPE_API.Overdue).toBe(8);
|
||||
|
||||
expect(mapWizardTypeToApi("")).toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -60,16 +60,16 @@ describe("toBoardQueryParams", () => {
|
|||
expect(params.overdue).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps Overdue bar filter to overdue=true without types", () => {
|
||||
it("maps the Overdue bar filter to the Overdue type, not the past-due flag", () => {
|
||||
const params = toBoardQueryParams({ ...baseBar, types: new Set(["Overdue"]) }, "");
|
||||
expect(params.overdue).toBe(true);
|
||||
expect(params.types).toBeUndefined();
|
||||
expect(params.types).toEqual([WORK_ORDER_TYPE_API.Overdue]);
|
||||
expect(params.overdue).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps Overdue with real types as OR (types + overdue)", () => {
|
||||
it("maps Overdue with other types as a plain type union", () => {
|
||||
const params = toBoardQueryParams({ ...baseBar, types: new Set(["PM", "Overdue"]) }, "");
|
||||
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]);
|
||||
expect(params.overdue).toBe(true);
|
||||
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM, WORK_ORDER_TYPE_API.Overdue]);
|
||||
expect(params.overdue).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits types when the set is empty", () => {
|
||||
|
|
@ -222,22 +222,23 @@ describe("toBoardSearchQueryParams", () => {
|
|||
expect(buildBoardSearchParamsRecord(params)).not.toHaveProperty("unscheduledOnly");
|
||||
});
|
||||
|
||||
it("maps Overdue-only advanced filter to overdue=true without types", () => {
|
||||
it("maps the Overdue type advanced filter to types without the past-due flag", () => {
|
||||
const params = toBoardSearchQueryParams({
|
||||
filters: {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
types: new Set(["Overdue"]),
|
||||
},
|
||||
});
|
||||
expect(params.overdue).toBe(true);
|
||||
expect(params.types).toBeUndefined();
|
||||
expect(params.types).toEqual([WORK_ORDER_TYPE_API.Overdue]);
|
||||
expect(params.overdue).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps Overdue with real types as OR (types + overdue)", () => {
|
||||
it("maps the Past Due status toggle to overdue=true alongside types", () => {
|
||||
const params = toBoardSearchQueryParams({
|
||||
filters: {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
types: new Set(["PM", "Overdue"]),
|
||||
types: new Set(["PM"]),
|
||||
pastDue: true,
|
||||
},
|
||||
});
|
||||
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]);
|
||||
|
|
|
|||
|
|
@ -172,26 +172,26 @@ describe("filterByBarAndSearch", () => {
|
|||
expect(ids).toEqual(["em", "rx"]);
|
||||
});
|
||||
|
||||
it("matches Overdue via isPastDue even when row.type is not Overdue", () => {
|
||||
it("matches the Overdue type only, never a past-due row of another type", () => {
|
||||
const pastDuePm = makeRow({
|
||||
id: "pd",
|
||||
type: "PM",
|
||||
isPastDue: true,
|
||||
scheduledOn: "2026-06-03",
|
||||
});
|
||||
const currentPm = makeRow({
|
||||
id: "now",
|
||||
type: "PM",
|
||||
const overdue = makeRow({
|
||||
id: "ov",
|
||||
type: "Overdue",
|
||||
isPastDue: false,
|
||||
scheduledOn: "2026-06-03",
|
||||
});
|
||||
|
||||
const ids = filterByBarAndSearch(
|
||||
[pastDuePm, currentPm],
|
||||
[pastDuePm, overdue],
|
||||
bar(weekMonday, { types: new Set(["Overdue"]) }),
|
||||
"",
|
||||
).map((row) => row.id);
|
||||
expect(ids).toEqual(["pd"]);
|
||||
expect(ids).toEqual(["ov"]);
|
||||
});
|
||||
|
||||
it("hides a row after type correction when the bar filter no longer matches", () => {
|
||||
|
|
@ -395,13 +395,8 @@ describe("matchesAdvancedFilters", () => {
|
|||
expect(matchesAdvancedFilters(row, noMatch)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches Overdue when isPastDue is true", () => {
|
||||
it("matches the Overdue type filter by type, independent of isPastDue", () => {
|
||||
const weekMonday = mondayOf(todayIso());
|
||||
const pastDue = makeRow({
|
||||
scheduledOn: weekMonday,
|
||||
...deriveDayFields(weekMonday),
|
||||
isPastDue: true,
|
||||
});
|
||||
const filters = {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
dateRange: "custom" as const,
|
||||
|
|
@ -409,9 +404,39 @@ describe("matchesAdvancedFilters", () => {
|
|||
customTo: "9999-12-31",
|
||||
types: new Set(["Overdue" as const]),
|
||||
};
|
||||
expect(matchesAdvancedFilters(pastDue, filters)).toBe(true);
|
||||
const overdue = makeRow({ type: "Overdue", isPastDue: false, scheduledOn: weekMonday });
|
||||
const pastDuePm = makeRow({ type: "PM", isPastDue: true, scheduledOn: weekMonday });
|
||||
expect(matchesAdvancedFilters(overdue, filters)).toBe(true);
|
||||
expect(matchesAdvancedFilters(pastDuePm, filters)).toBe(false);
|
||||
});
|
||||
|
||||
it("narrows to past-due rows with the Past Due status toggle", () => {
|
||||
const weekMonday = mondayOf(todayIso());
|
||||
const filters = {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
dateRange: "custom" as const,
|
||||
customFrom: "0000-01-01",
|
||||
customTo: "9999-12-31",
|
||||
types: new Set(["PM" as const]),
|
||||
pastDue: true,
|
||||
};
|
||||
expect(
|
||||
matchesAdvancedFilters(makeRow({ isPastDue: false, scheduledOn: weekMonday }), filters),
|
||||
matchesAdvancedFilters(
|
||||
makeRow({ type: "PM", isPastDue: true, scheduledOn: weekMonday }),
|
||||
filters,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesAdvancedFilters(
|
||||
makeRow({ type: "PM", isPastDue: false, scheduledOn: weekMonday }),
|
||||
filters,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
matchesAdvancedFilters(
|
||||
makeRow({ type: "Overdue", isPastDue: true, scheduledOn: weekMonday }),
|
||||
filters,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue