mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 20:53:12 +00:00
feat(work-orders): integrate board list, search and field patch API
This commit is contained in:
parent
94bd10245b
commit
3ea136ce3c
86 changed files with 6415 additions and 1352 deletions
|
|
@ -1,5 +1,15 @@
|
|||
import { isHTTPError, isNetworkError, isTimeoutError } from "ky";
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export function mapHttpStatusToMessage(status: number, data?: unknown): string {
|
||||
if (status === 401) {
|
||||
return "You are not authorized to access this page.";
|
||||
|
|
|
|||
|
|
@ -26,6 +26,19 @@ export const API_PATHS = {
|
|||
addDispatchComment: "WorkOrder/AddDispatchComment",
|
||||
dispatchToVendor: "WorkOrder/DispatchToVendor",
|
||||
changeStatus: "WorkOrder/ChangeStatus",
|
||||
board: "workorders/board",
|
||||
boardCreate: "workorders/board",
|
||||
boardSearch: "workorders/board/search",
|
||||
boardPatch: (id: string | number) => `workorders/${id}/board`,
|
||||
boardDetail: (id: string | number) => `workorders/${id}/detail`,
|
||||
comments: (id: string | number) => `workorders/${id}/comments`,
|
||||
commentItem: (workOrderId: string | number, commentId: string | number) =>
|
||||
`workorders/${workOrderId}/comments/${commentId}`,
|
||||
media: (id: string | number) => `workorders/${id}/media`,
|
||||
mediaItem: (id: string | number, mediaId: string | number) =>
|
||||
`workorders/${id}/media/${mediaId}`,
|
||||
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
|
||||
lookupsDispatchers: "workorders/lookups/dispatchers",
|
||||
},
|
||||
pmSchedule: {
|
||||
getList: "PMSchedule/GetList",
|
||||
|
|
@ -77,6 +90,7 @@ export const API_PATHS = {
|
|||
delete: "Location/Delete",
|
||||
deleteLocation: "Location/DeleteLocation",
|
||||
dropdown: "Location/Dropdown",
|
||||
sites: "locations/sites",
|
||||
},
|
||||
account: {
|
||||
getList: "Account/GetAccountList",
|
||||
|
|
@ -108,7 +122,7 @@ export const API_PATHS = {
|
|||
create: "Vendor/Create",
|
||||
update: "Vendor/Update",
|
||||
delete: "Vendor/Delete",
|
||||
dropdown: "Vendor/Dropdown",
|
||||
dropdown: "vendors/Dropdown",
|
||||
facets: "Vendor/facets",
|
||||
deactivationImpact: "deactivation-impact",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -41,8 +41,11 @@ export function handlePaginatedResponse<T>(
|
|||
}
|
||||
|
||||
if (isRecord(data)) {
|
||||
const nested = data.data ?? data.Data ?? data.value ?? data;
|
||||
const items = Array.isArray(nested) ? (nested as T[]) : [];
|
||||
const nested = data.data ?? data.Data ?? data.value;
|
||||
const itemsFromPaged =
|
||||
(Array.isArray(data.items) ? (data.items as T[]) : undefined) ??
|
||||
(Array.isArray(data.Items) ? (data.Items as T[]) : undefined);
|
||||
const items = itemsFromPaged ?? (Array.isArray(nested) ? (nested as T[]) : []);
|
||||
|
||||
const totalCount =
|
||||
(typeof data.totalCount === "number" ? data.totalCount : undefined) ??
|
||||
|
|
@ -54,6 +57,7 @@ export function handlePaginatedResponse<T>(
|
|||
items,
|
||||
totalCount,
|
||||
page:
|
||||
(typeof data.page === "number" ? data.page : undefined) ??
|
||||
(typeof data.pageNumber === "number" ? data.pageNumber : undefined) ??
|
||||
(typeof data.PageNumber === "number" ? data.PageNumber : undefined) ??
|
||||
page,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
import { useMemo } from "react";
|
||||
import { Popover } from "@mui/material";
|
||||
import { Popover, Skeleton } from "@mui/material";
|
||||
import { ChevronDown, UserX, Users } from "lucide-react";
|
||||
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
|
||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
MOCK_DISPATCHER_USERS,
|
||||
getDispatcherAvatarColor,
|
||||
} from "@/domain/work-orders/mocks/dispatcher-filter-mock-data";
|
||||
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||
import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item";
|
||||
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
|
||||
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
||||
|
|
@ -15,6 +12,7 @@ type DispatcherFilterProps = {
|
|||
selectedIds: Set<string>;
|
||||
onChange: (ids: Set<string>) => void;
|
||||
users: WorkOrderUser[];
|
||||
usersLoading?: boolean;
|
||||
currentUserId?: string | number;
|
||||
};
|
||||
|
||||
|
|
@ -40,6 +38,7 @@ export function DispatcherFilter({
|
|||
selectedIds,
|
||||
onChange,
|
||||
users,
|
||||
usersLoading = false,
|
||||
currentUserId,
|
||||
}: DispatcherFilterProps) {
|
||||
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||
|
|
@ -57,11 +56,7 @@ export function DispatcherFilter({
|
|||
}, [allSelected, meOnly, unassignedOnly, selectedIds.size]);
|
||||
|
||||
const dispatchers = useMemo(() => {
|
||||
const byId = new Map<string, WorkOrderUser>();
|
||||
for (const user of [...MOCK_DISPATCHER_USERS, ...users]) {
|
||||
byId.set(String(user.id), user);
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
return [...users].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [users]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
|
|
@ -74,6 +69,18 @@ export function DispatcherFilter({
|
|||
onChange(next);
|
||||
};
|
||||
|
||||
if (usersLoading && users.length === 0) {
|
||||
return (
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width={160}
|
||||
height={38}
|
||||
animation="wave"
|
||||
aria-label="Loading dispatchers"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuAnchorButton
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ type WorkOrdersFilterBarProps = {
|
|||
typeFilter: WorkOrderTypeFilter;
|
||||
onTypeChange: (value: WorkOrderTypeFilter) => void;
|
||||
users: WorkOrderUser[];
|
||||
usersLoading?: boolean;
|
||||
currentUserId?: string | number;
|
||||
filteredCount: number;
|
||||
scopeTotalCount: number;
|
||||
|
|
@ -25,6 +26,7 @@ export function WorkOrdersFilterBar({
|
|||
typeFilter,
|
||||
onTypeChange,
|
||||
users,
|
||||
usersLoading = false,
|
||||
currentUserId,
|
||||
filteredCount,
|
||||
scopeTotalCount,
|
||||
|
|
@ -37,6 +39,7 @@ export function WorkOrdersFilterBar({
|
|||
selectedIds={dispatcherIds}
|
||||
onChange={onDispatcherChange}
|
||||
users={users}
|
||||
usersLoading={usersLoading}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
<WeekNav weekMonday={weekMonday} onChange={onWeekChange} />
|
||||
|
|
|
|||
|
|
@ -119,7 +119,6 @@ export function DateCell({
|
|||
<WeekPicker
|
||||
selected={!value ? targetWeek : ""}
|
||||
onPick={(mon) => {
|
||||
onSave("");
|
||||
onSaveWeek?.(mon);
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
import { getDispatcherAvatarColor } from "@/domain/work-orders/mocks/dispatcher-filter-mock-data";
|
||||
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||
|
||||
import { HighlightText } from "./highlight-text";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,10 @@
|
|||
import { useState } from "react";
|
||||
import { Flag, X } from "lucide-react";
|
||||
import { Popover } from "@mui/material";
|
||||
|
||||
const FLAG_COLORS: { name: string; value: string }[] = [
|
||||
{ name: "Red", value: "#EF4444" },
|
||||
{ name: "Orange", value: "#F97316" },
|
||||
{ name: "Amber", value: "#F59E0B" },
|
||||
{ name: "Yellow", value: "#EAB308" },
|
||||
{ name: "Green", value: "#22C55E" },
|
||||
{ name: "Teal", value: "#14B8A6" },
|
||||
{ name: "Blue", value: "#3B82F6" },
|
||||
{ name: "Purple", value: "#8B5CF6" },
|
||||
];
|
||||
import { FLAG_COLORS } from "@/domain/work-orders/flag-colors";
|
||||
|
||||
type FlagCellProps = {
|
||||
color?: string;
|
||||
color?: string | null;
|
||||
disabled?: boolean;
|
||||
onChange: (color: string | null) => void;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,41 +1,129 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { Check, Plus } from "lucide-react";
|
||||
import { Popover } from "@mui/material";
|
||||
|
||||
import { PmTypeIcon } from "../pm-type-icons";
|
||||
|
||||
import { HighlightText } from "./highlight-text";
|
||||
|
||||
import { HoverChevron } from "./hover-chevron";
|
||||
import {
|
||||
ServiceNotesDialog,
|
||||
type ServiceNotesDetails,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog";
|
||||
import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text";
|
||||
import { HoverChevron } from "@/app/(protected)/workorders/_components/list/table/cells/hover-chevron";
|
||||
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||
import {
|
||||
hasServiceNotesDetails,
|
||||
truncateServiceNotesPreview,
|
||||
} from "@/domain/work-orders/utils/service-notes-ui";
|
||||
|
||||
type PMTypeCellProps = {
|
||||
value: string;
|
||||
|
||||
pmTypes: string[];
|
||||
|
||||
q?: string;
|
||||
|
||||
onSave: (v: string) => void;
|
||||
serviceNotes?: string;
|
||||
extraServices?: string[];
|
||||
woNumber?: string;
|
||||
onSaveDetails?: (details: ServiceNotesDetails) => void;
|
||||
};
|
||||
|
||||
export function PMTypeCell({ value, pmTypes, q, onSave }: PMTypeCellProps) {
|
||||
export function PMTypeCell({
|
||||
value,
|
||||
pmTypes,
|
||||
q,
|
||||
onSave,
|
||||
serviceNotes = "",
|
||||
extraServices = [],
|
||||
woNumber,
|
||||
onSaveDetails,
|
||||
}: PMTypeCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const filtered = search
|
||||
? pmTypes.filter((p) => p.toLowerCase().includes(search.toLowerCase()))
|
||||
: pmTypes;
|
||||
|
||||
const extras = extraServices ?? [];
|
||||
const hasDetails = hasServiceNotesDetails(serviceNotes, extras);
|
||||
const notePreview = serviceNotes?.trim() ? truncateServiceNotesPreview(serviceNotes) : "";
|
||||
|
||||
const modal =
|
||||
onSaveDetails != null ? (
|
||||
<ServiceNotesDialog
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
pm={value}
|
||||
extraServices={extras}
|
||||
serviceNotes={serviceNotes ?? ""}
|
||||
woNumber={woNumber}
|
||||
pmTypes={pmTypes}
|
||||
onSave={onSaveDetails}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (hasDetails && onSaveDetails) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
className="flex w-full min-w-0 flex-col items-start text-left leading-tight transition-opacity hover:opacity-80"
|
||||
>
|
||||
<span className="flex w-full min-w-0 items-center gap-1.5">
|
||||
<PmTypeIcon
|
||||
pmType={value}
|
||||
size={12}
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
<span
|
||||
className="min-w-0 truncate"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: value ? "var(--color-text-primary)" : "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{value ? <HighlightText text={value} q={q} /> : "Select …"}
|
||||
</span>
|
||||
</span>
|
||||
{notePreview ? (
|
||||
<span
|
||||
className="w-full min-w-0 truncate"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontStyle: "italic",
|
||||
color: "var(--color-text-muted)",
|
||||
paddingLeft: 18,
|
||||
}}
|
||||
>
|
||||
{notePreview}
|
||||
</span>
|
||||
) : extras.length > 0 ? (
|
||||
<span
|
||||
className="w-full min-w-0 truncate"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-text-muted)",
|
||||
paddingLeft: 18,
|
||||
}}
|
||||
>
|
||||
+{extras.length} more
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
|
||||
|
|
@ -46,13 +134,11 @@ export function PMTypeCell({ value, pmTypes, q, onSave }: PMTypeCellProps) {
|
|||
className="shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
|
||||
{value ? (
|
||||
<span
|
||||
className="min-w-0 truncate"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
|
||||
color: "var(--color-text-primary)",
|
||||
}}
|
||||
>
|
||||
|
|
@ -61,7 +147,6 @@ export function PMTypeCell({ value, pmTypes, q, onSave }: PMTypeCellProps) {
|
|||
) : (
|
||||
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>Select …</span>
|
||||
)}
|
||||
|
||||
<HoverChevron />
|
||||
</button>
|
||||
|
||||
|
|
@ -70,7 +155,6 @@ export function PMTypeCell({ value, pmTypes, q, onSave }: PMTypeCellProps) {
|
|||
anchorEl={anchorEl}
|
||||
onClose={() => {
|
||||
setAnchorEl(null);
|
||||
|
||||
setSearch("");
|
||||
}}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
|
|
@ -88,33 +172,50 @@ export function PMTypeCell({ value, pmTypes, q, onSave }: PMTypeCellProps) {
|
|||
</div>
|
||||
|
||||
<div className="max-h-52 overflow-y-auto">
|
||||
{onSaveDetails ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAnchorEl(null);
|
||||
setSearch("");
|
||||
setModalOpen(true);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] font-medium transition-colors"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "var(--color-primary-tint)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<Plus size={12} className="shrink-0" /> Add service and notes
|
||||
</button>
|
||||
) : null}
|
||||
{filtered.map((pm) => (
|
||||
<button
|
||||
key={pm}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSave(pm);
|
||||
|
||||
setAnchorEl(null);
|
||||
|
||||
setSearch("");
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
|
||||
>
|
||||
<PmTypeIcon pmType={pm} size={12} style={{ color: "var(--color-text-muted)" }} />
|
||||
|
||||
<span
|
||||
className="flex-1 truncate text-left text-xs"
|
||||
style={{ color: "var(--color-text-primary)" }}
|
||||
>
|
||||
{pm}
|
||||
</span>
|
||||
|
||||
{value === pm && <Check size={10} className="text-[var(--color-primary)]" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Popover>
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Wrench } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { WizardServiceNotesFields } from "@/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
|
||||
export type ServiceNotesDetails = {
|
||||
pm: string;
|
||||
extraServices: string[];
|
||||
serviceNotes: string;
|
||||
};
|
||||
|
||||
type ServiceNotesDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
pm: string;
|
||||
extraServices: string[];
|
||||
serviceNotes: string;
|
||||
woNumber?: string;
|
||||
pmTypes: string[];
|
||||
onSave: (details: ServiceNotesDetails) => void;
|
||||
};
|
||||
|
||||
export function ServiceNotesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
pm,
|
||||
extraServices,
|
||||
serviceNotes,
|
||||
woNumber,
|
||||
pmTypes,
|
||||
onSave,
|
||||
}: ServiceNotesDialogProps) {
|
||||
const [sel, setSel] = useState<string[]>([]);
|
||||
const [note, setNote] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setSel([pm, ...(extraServices ?? [])].filter((p, i, a) => !!p && a.indexOf(p) === i));
|
||||
setNote(serviceNotes ?? "");
|
||||
setSearch("");
|
||||
}, [open, pm, extraServices, serviceNotes]);
|
||||
|
||||
const toggle = (value: string) => {
|
||||
setSel((cur) => (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value]));
|
||||
};
|
||||
|
||||
const services = pmTypes.map((value) => ({ id: value, value }));
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
slotProps={dialogBlurBackdropSlotProps}
|
||||
>
|
||||
<DialogTitle
|
||||
className="flex items-center gap-2"
|
||||
style={{ fontSize: 16, color: "var(--color-text-primary)" }}
|
||||
>
|
||||
<Wrench size={16} style={{ color: "var(--color-text-muted)" }} />
|
||||
Service & Notes
|
||||
{woNumber ? (
|
||||
<span style={{ fontSize: 13, fontWeight: 400, color: "var(--color-text-muted)" }}>
|
||||
#{woNumber}
|
||||
</span>
|
||||
) : null}
|
||||
</DialogTitle>
|
||||
<DialogContent
|
||||
className="space-y-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ paddingTop: 8 }}
|
||||
>
|
||||
<WizardServiceNotesFields
|
||||
services={services}
|
||||
sel={sel}
|
||||
onToggle={toggle}
|
||||
note={note}
|
||||
setNote={setNote}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-0 px-2 py-1 text-[12px]"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
onClick={() => {
|
||||
onSave({
|
||||
pm: sel[0] ?? "",
|
||||
extraServices: sel.slice(1),
|
||||
serviceNotes: note,
|
||||
});
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from "react";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { formatSitePlace } from "@/domain/locations/mappers/location-mapper";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
import { HighlightText } from "./highlight-text";
|
||||
import { HoverChevron } from "./hover-chevron";
|
||||
|
|
@ -9,6 +10,8 @@ type SiteCellProps = {
|
|||
value: string;
|
||||
location: string;
|
||||
locationId: string | number;
|
||||
city?: string;
|
||||
state?: string;
|
||||
pocName?: string;
|
||||
pocPhone?: string;
|
||||
pocNotes?: string;
|
||||
|
|
@ -21,6 +24,8 @@ export function SiteCell({
|
|||
value,
|
||||
location,
|
||||
locationId,
|
||||
city,
|
||||
state,
|
||||
pocName,
|
||||
pocPhone,
|
||||
pocNotes,
|
||||
|
|
@ -29,6 +34,7 @@ export function SiteCell({
|
|||
onSave,
|
||||
}: SiteCellProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const place = formatSitePlace(city, state, location);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -49,11 +55,16 @@ export function SiteCell({
|
|||
>
|
||||
<HighlightText text={value} q={q} />
|
||||
</span>
|
||||
{Boolean(location) && (
|
||||
{Boolean(place) && (
|
||||
<>
|
||||
<span className="shrink-0 text-[var(--color-text-muted)]">·</span>
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center self-center text-[12.5px] leading-none text-[var(--color-text-muted)]"
|
||||
aria-hidden
|
||||
>
|
||||
.
|
||||
</span>
|
||||
<span className="truncate text-xs text-[var(--muted-foreground)]">
|
||||
<HighlightText text={location} q={q} />
|
||||
<HighlightText text={place} q={q} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ import { Button } from "@/components/ui/button";
|
|||
import { Text } from "@/components/ui/text";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||
import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
|
||||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
export type SitePatch = {
|
||||
|
|
@ -47,27 +48,45 @@ function FieldLabel({ children }: { children: ReactNode }) {
|
|||
);
|
||||
}
|
||||
|
||||
function extractSiteCode(name: string): string {
|
||||
const dash = name.indexOf(" — ");
|
||||
if (dash > 0) {
|
||||
return name.slice(0, dash).trim();
|
||||
function extractLocationLabel(site: LocationOption): string {
|
||||
const place = [site.city, site.state].filter(Boolean).join(", ");
|
||||
if (place) {
|
||||
return place;
|
||||
}
|
||||
return name.slice(0, 8).toUpperCase();
|
||||
}
|
||||
|
||||
function extractLocationLabel(name: string): string {
|
||||
const dash = name.indexOf(" — ");
|
||||
if (dash > 0) {
|
||||
return name.slice(dash + 3).trim();
|
||||
const name = site.name;
|
||||
for (const sep of [" · ", " — ", " - "]) {
|
||||
const idx = name.indexOf(sep);
|
||||
if (idx > 0) {
|
||||
return name.slice(idx + sep.length).trim();
|
||||
}
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Board rows often have siteCode but no locationId — resolve via sites list. */
|
||||
function resolveLocationId(
|
||||
locationId: string | number,
|
||||
siteCode: string,
|
||||
sites: LocationOption[],
|
||||
): string {
|
||||
const idStr = locationId !== "" && locationId != null ? String(locationId) : "";
|
||||
if (idStr && sites.some((s) => String(s.id) === idStr)) {
|
||||
return idStr;
|
||||
}
|
||||
if (idStr && sites.length === 0) {
|
||||
return idStr;
|
||||
}
|
||||
const code = siteCode.trim().toLowerCase();
|
||||
if (!code) {
|
||||
return idStr;
|
||||
}
|
||||
const byCode = sites.find((s) => resolveSiteCode(s).toLowerCase() === code);
|
||||
return byCode ? String(byCode.id) : idStr;
|
||||
}
|
||||
|
||||
function buildMapsUrl(location: Location): string {
|
||||
const parts = [location.address, location.city, location.state, location.zipCode]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(parts)}`;
|
||||
const query = formatLocationAddressPreview(location);
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
|
||||
}
|
||||
|
||||
export function SiteDialog({
|
||||
|
|
@ -82,28 +101,34 @@ export function SiteDialog({
|
|||
onSave,
|
||||
}: SiteDialogProps) {
|
||||
const [code, setCode] = useState(value);
|
||||
const [locId, setLocId] = useState(String(locationId || ""));
|
||||
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
|
||||
const [pn, setPn] = useState(pocName);
|
||||
const [pp, setPp] = useState(pocPhone);
|
||||
const [notes, setNotes] = useState(pocNotes);
|
||||
const [pocFilledFor, setPocFilledFor] = useState("");
|
||||
|
||||
const isMockLocation = locId.startsWith("mock-");
|
||||
const { data: apiLocationDetail } = useLocationDetail(
|
||||
isMockLocation || !locId ? undefined : locId,
|
||||
);
|
||||
const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail;
|
||||
const {
|
||||
data: locationDetail,
|
||||
isLoading: locationDetailLoading,
|
||||
isError: locationDetailError,
|
||||
} = useLocationDetail(open && locId ? locId : undefined);
|
||||
|
||||
const selected = sites.find((s) => String(s.id) === locId);
|
||||
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCode(value);
|
||||
setLocId(String(locationId || ""));
|
||||
setPn(pocName);
|
||||
setPp(pocPhone);
|
||||
setNotes(pocNotes);
|
||||
setPocFilledFor(String(locationId || ""));
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
}, [open, value, locationId, pocName, pocPhone, pocNotes]);
|
||||
const resolved = resolveLocationId(locationId, value, sites);
|
||||
setCode(value);
|
||||
setLocId(resolved);
|
||||
setPn(pocName);
|
||||
setPp(pocPhone);
|
||||
setNotes(pocNotes);
|
||||
// Preserve existing WO POC; allow one auto-fill from site detail when POC is empty.
|
||||
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
|
||||
}, [open, value, locationId, pocName, pocPhone, pocNotes, sites]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
|
||||
|
|
@ -114,13 +139,11 @@ export function SiteDialog({
|
|||
setPocFilledFor(locId);
|
||||
}, [open, locId, locationDetail, pocFilledFor]);
|
||||
|
||||
const selected = sites.find((s) => String(s.id) === locId);
|
||||
|
||||
const handlePick = (id: string) => {
|
||||
const next = sites.find((s) => String(s.id) === id);
|
||||
setLocId(id);
|
||||
if (next) {
|
||||
setCode(extractSiteCode(next.name));
|
||||
setCode(resolveSiteCode(next));
|
||||
}
|
||||
if (id !== pocFilledFor) {
|
||||
setPocFilledFor("");
|
||||
|
|
@ -171,7 +194,7 @@ export function SiteDialog({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{locationDetail != null && Boolean(locId) && (
|
||||
{Boolean(locId) && (
|
||||
<div
|
||||
className="space-y-1.5 rounded-lg p-3"
|
||||
style={{
|
||||
|
|
@ -179,68 +202,85 @@ export function SiteDialog({
|
|||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<Detail
|
||||
icon={MapPin}
|
||||
text={
|
||||
[
|
||||
locationDetail.address,
|
||||
locationDetail.city,
|
||||
locationDetail.state,
|
||||
locationDetail.zipCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ") || "—"
|
||||
}
|
||||
/>
|
||||
{locationDetail.phone != null && <Detail icon={Phone} text={locationDetail.phone} />}
|
||||
{locationDetail.contactEmail != null && (
|
||||
<Detail icon={Mail} text={locationDetail.contactEmail} />
|
||||
)}
|
||||
{Boolean(locationDetail.address) && (
|
||||
<a
|
||||
href={buildMapsUrl(locationDetail)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
<ExternalLink size={12} /> Open in Google Maps
|
||||
</a>
|
||||
{locationDetail ? (
|
||||
<>
|
||||
<Detail icon={MapPin} text={addressPreview || selected?.name || code || "—"} />
|
||||
{Boolean(locationDetail.phone) && (
|
||||
<Detail icon={Phone} text={locationDetail.phone} />
|
||||
)}
|
||||
{Boolean(locationDetail.contactEmail) && (
|
||||
<Detail icon={Mail} text={locationDetail.contactEmail} />
|
||||
)}
|
||||
{Boolean(addressPreview) && (
|
||||
<a
|
||||
href={buildMapsUrl(locationDetail)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
<ExternalLink size={12} /> Open in Google Maps
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Detail icon={MapPin} text={selected?.name || code || "—"} />
|
||||
{locationDetailLoading && (
|
||||
<div
|
||||
className="space-y-1.5"
|
||||
aria-busy="true"
|
||||
role="status"
|
||||
aria-label="Loading site details"
|
||||
>
|
||||
<span
|
||||
className="block animate-pulse rounded"
|
||||
style={{ background: "var(--color-bg-muted)", width: "75%", height: 12 }}
|
||||
/>
|
||||
<span
|
||||
className="block animate-pulse rounded"
|
||||
style={{ background: "var(--color-bg-muted)", width: "50%", height: 12 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{locationDetailError && (
|
||||
<Text as="p" style={{ fontSize: 12, color: "var(--muted-foreground)" }}>
|
||||
Could not load full site details. City and site code above are still
|
||||
available.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-1">
|
||||
<FieldLabel>Point of Contact</FieldLabel>
|
||||
<Text
|
||||
as="p"
|
||||
className="mb-2"
|
||||
style={{ fontSize: 11, color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Auto-filled from records — edit if outdated
|
||||
<Text as="p" className="mb-2 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
Shown from site/work order records. Board edit saves site code only.
|
||||
</Text>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={pn}
|
||||
onChange={(e) => setPn(e.target.value)}
|
||||
readOnly
|
||||
placeholder="POC name"
|
||||
className={DLG_INPUT_CLS}
|
||||
style={DLG_INPUT_STYLE}
|
||||
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
|
||||
/>
|
||||
<input
|
||||
value={pp}
|
||||
onChange={(e) => setPp(e.target.value)}
|
||||
readOnly
|
||||
placeholder="POC phone"
|
||||
className={DLG_INPUT_CLS}
|
||||
style={DLG_INPUT_STYLE}
|
||||
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
|
||||
/>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
readOnly
|
||||
placeholder="Notes…"
|
||||
rows={2}
|
||||
className={`${DLG_INPUT_CLS} resize-none`}
|
||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit", opacity: 0.85 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -258,8 +298,8 @@ export function SiteDialog({
|
|||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
onClick={() => {
|
||||
onSave({
|
||||
site: code || (selected ? extractSiteCode(selected.name) : ""),
|
||||
location: selected ? extractLocationLabel(selected.name) : "",
|
||||
site: code || (selected ? resolveSiteCode(selected) : ""),
|
||||
location: selected ? extractLocationLabel(selected) : "",
|
||||
locationId: locId,
|
||||
pocName: pn,
|
||||
pocPhone: pp,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react";
|
||||
import { ExternalLink, Mail, Wrench } from "lucide-react";
|
||||
import { ExternalLink, Mail, User, Wrench } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||
import {
|
||||
|
|
@ -11,8 +11,13 @@ import { Button } from "@/components/ui/button";
|
|||
import { Text } from "@/components/ui/text";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
formatVendorDistance,
|
||||
formatVendorTechnicianLabel,
|
||||
} from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||
|
||||
export type VendorPatch = {
|
||||
vendorId: string;
|
||||
company: string;
|
||||
tech: string;
|
||||
techPhone: string;
|
||||
|
|
@ -37,13 +42,6 @@ type TechnicianOption = {
|
|||
vendor?: VendorDropdownItem;
|
||||
};
|
||||
|
||||
/** Known tech ↔ company pairs from seeded table mock data. */
|
||||
const MOCK_TECH_BY_COMPANY: Record<string, { name: string; phone: string }> = {
|
||||
"Midwest HVAC Co.": { name: "Mike Torres", phone: "(312) 555-0198" },
|
||||
"Great Lakes Plumbing": { name: "Jen Walsh", phone: "(317) 555-4412" },
|
||||
"SparkLine Electric": { name: "Pat Nguyen", phone: "" },
|
||||
};
|
||||
|
||||
function FieldLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<label
|
||||
|
|
@ -70,23 +68,11 @@ function buildTechnicianOptions(
|
|||
};
|
||||
|
||||
for (const vendor of vendors) {
|
||||
const mockTech = MOCK_TECH_BY_COMPANY[vendor.companyName];
|
||||
if (mockTech) {
|
||||
add({
|
||||
key: mockTech.name,
|
||||
label: `${mockTech.name} · ${vendor.companyName}`,
|
||||
name: mockTech.name,
|
||||
company: vendor.companyName,
|
||||
phone: mockTech.phone,
|
||||
vendor,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const contact = vendor.contactName.trim() || vendor.companyName.trim();
|
||||
add({
|
||||
key: vendor.companyName,
|
||||
label: vendor.companyName,
|
||||
name: vendor.companyName,
|
||||
key: String(vendor.id),
|
||||
label: formatVendorTechnicianLabel(vendor),
|
||||
name: contact,
|
||||
company: vendor.companyName,
|
||||
phone: "",
|
||||
vendor,
|
||||
|
|
@ -94,9 +80,13 @@ function buildTechnicianOptions(
|
|||
}
|
||||
|
||||
if (tech) {
|
||||
const vendor = vendors.find((v) => v.companyName === company);
|
||||
const vendor =
|
||||
vendors.find((v) => v.contactName === tech && v.companyName === company) ??
|
||||
vendors.find((v) => v.contactName === tech) ??
|
||||
vendors.find((v) => v.companyName === company);
|
||||
const key = vendor ? String(vendor.id) : tech;
|
||||
add({
|
||||
key: tech,
|
||||
key,
|
||||
label: company && company !== tech ? `${tech} · ${company}` : tech,
|
||||
name: tech,
|
||||
company: company || tech,
|
||||
|
|
@ -138,12 +128,18 @@ export function VendorDialog({
|
|||
}
|
||||
}, [open, tech, techPhone, technicianOptions]);
|
||||
|
||||
const selected = technicianOptions.find((o) => o.name === t);
|
||||
const selected =
|
||||
technicianOptions.find((o) => o.name === t && (!company || o.company === company)) ??
|
||||
technicianOptions.find((o) => o.name === t || o.key === t);
|
||||
const selectedKey = selected?.key ?? "";
|
||||
const resolvedCompany = t ? (selected?.company ?? company) : "";
|
||||
const selectedVendor = selected?.vendor ?? vendors.find((v) => v.companyName === resolvedCompany);
|
||||
const selectedDistance = formatVendorDistance(selectedVendor?.distanceMiles);
|
||||
const companyEmail = selectedVendor?.email?.trim() || "";
|
||||
|
||||
const handlePick = (name: string) => {
|
||||
const handlePick = (key: string) => {
|
||||
const option = technicianOptions.find((o) => o.key === key);
|
||||
const name = option?.name ?? key;
|
||||
setT(name);
|
||||
setPhone(phoneForOption(name, technicianOptions));
|
||||
};
|
||||
|
|
@ -174,7 +170,7 @@ export function VendorDialog({
|
|||
<div>
|
||||
<FieldLabel>Technician</FieldLabel>
|
||||
<WizardFieldSelect
|
||||
value={t}
|
||||
value={selectedKey}
|
||||
placeholder="Search technician …"
|
||||
icon={
|
||||
<Wrench
|
||||
|
|
@ -215,11 +211,27 @@ export function VendorDialog({
|
|||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
|
||||
{resolvedCompany || "—"}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
|
||||
{resolvedCompany || "—"}
|
||||
</div>
|
||||
{selectedDistance ? (
|
||||
<span
|
||||
className="shrink-0 text-[11px]"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
{selectedDistance}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{Boolean(selectedVendor?.contactName) && (
|
||||
<Detail icon={User} text={selectedVendor?.contactName ?? ""} />
|
||||
)}
|
||||
{Boolean(selectedVendor?.tradeSpecialties) && (
|
||||
<Detail icon={Wrench} text={selectedVendor?.tradeSpecialties ?? ""} />
|
||||
)}
|
||||
{Boolean(companyEmail) && <Detail icon={Mail} text={companyEmail} />}
|
||||
{selectedVendor != null && selectedVendor.address != null && (
|
||||
{selectedVendor != null && Boolean(selectedVendor.address) && (
|
||||
<a
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(selectedVendor.address)}`}
|
||||
target="_blank"
|
||||
|
|
@ -258,7 +270,12 @@ export function VendorDialog({
|
|||
<Button
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
onClick={() => {
|
||||
onSave({ company: resolvedCompany, tech: t, techPhone: phone });
|
||||
onSave({
|
||||
vendorId: selectedVendor ? String(selectedVendor.id) : "",
|
||||
company: resolvedCompany,
|
||||
tech: t,
|
||||
techPhone: phone,
|
||||
});
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
AlertTriangle,
|
||||
Droplets,
|
||||
Filter,
|
||||
Flame,
|
||||
Gauge,
|
||||
Lightbulb,
|
||||
Lock,
|
||||
|
|
@ -17,25 +18,111 @@ import {
|
|||
Zap,
|
||||
} from "lucide-react";
|
||||
|
||||
export const PM_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
"Scrubber Pit": Filter,
|
||||
"Backflow Preventers": Shield,
|
||||
"Domestic Backflow": ShieldCheck,
|
||||
"Tank / Water Heater": Thermometer,
|
||||
"Pressure Reducing Valve": Gauge,
|
||||
"Pumping Systems": Waves,
|
||||
"HVAC Inspection": Wind,
|
||||
Electrical: Zap,
|
||||
Plumbing: Wrench,
|
||||
"Door / Hardware": Lock,
|
||||
"Floor Drain Backing Up": Droplets,
|
||||
"Parking Lot Lighting": Lightbulb,
|
||||
"Mens Restroom Camera Line": Video,
|
||||
"Assess Window Leaks": AlertTriangle,
|
||||
/**
|
||||
* Frontend-only icon keys for service / PM types.
|
||||
* Display mapping only — never sent to or stored by the API.
|
||||
*/
|
||||
export enum ServiceIcon {
|
||||
ScrubberPit = "scrubber-pit",
|
||||
BackflowPreventers = "backflow-preventers",
|
||||
DomesticBackflow = "domestic-backflow",
|
||||
TankWaterHeater = "tank-water-heater",
|
||||
PressureReducingValve = "pressure-reducing-valve",
|
||||
PumpingSystems = "pumping-systems",
|
||||
HvacInspection = "hvac-inspection",
|
||||
Electrical = "electrical",
|
||||
Plumbing = "plumbing",
|
||||
DoorHardware = "door-hardware",
|
||||
FloorDrainBackingUp = "floor-drain-backing-up",
|
||||
ParkingLotLighting = "parking-lot-lighting",
|
||||
MensRestroomCameraLine = "mens-restroom-camera-line",
|
||||
AssessWindowLeaks = "assess-window-leaks",
|
||||
FireSuppression = "fire-suppression",
|
||||
Leak = "leak",
|
||||
Default = "default",
|
||||
}
|
||||
|
||||
const SERVICE_ICON_COMPONENTS: Record<ServiceIcon, LucideIcon> = {
|
||||
[ServiceIcon.ScrubberPit]: Filter,
|
||||
[ServiceIcon.BackflowPreventers]: Shield,
|
||||
[ServiceIcon.DomesticBackflow]: ShieldCheck,
|
||||
[ServiceIcon.TankWaterHeater]: Thermometer,
|
||||
[ServiceIcon.PressureReducingValve]: Gauge,
|
||||
[ServiceIcon.PumpingSystems]: Waves,
|
||||
[ServiceIcon.HvacInspection]: Wind,
|
||||
[ServiceIcon.Electrical]: Zap,
|
||||
[ServiceIcon.Plumbing]: Wrench,
|
||||
[ServiceIcon.DoorHardware]: Lock,
|
||||
[ServiceIcon.FloorDrainBackingUp]: Droplets,
|
||||
[ServiceIcon.ParkingLotLighting]: Lightbulb,
|
||||
[ServiceIcon.MensRestroomCameraLine]: Video,
|
||||
[ServiceIcon.AssessWindowLeaks]: AlertTriangle,
|
||||
[ServiceIcon.FireSuppression]: Flame,
|
||||
[ServiceIcon.Leak]: Droplets,
|
||||
[ServiceIcon.Default]: Wrench,
|
||||
};
|
||||
|
||||
/** Exact label match (case-insensitive) — design PM types + common API Problems. */
|
||||
const SERVICE_LABEL_TO_ICON: Record<string, ServiceIcon> = {
|
||||
"scrubber pit": ServiceIcon.ScrubberPit,
|
||||
"backflow preventers": ServiceIcon.BackflowPreventers,
|
||||
"domestic backflow": ServiceIcon.DomesticBackflow,
|
||||
"tank / water heater": ServiceIcon.TankWaterHeater,
|
||||
"pressure reducing valve": ServiceIcon.PressureReducingValve,
|
||||
"pumping systems": ServiceIcon.PumpingSystems,
|
||||
"hvac inspection": ServiceIcon.HvacInspection,
|
||||
electrical: ServiceIcon.Electrical,
|
||||
plumbing: ServiceIcon.Plumbing,
|
||||
"door / hardware": ServiceIcon.DoorHardware,
|
||||
"floor drain backing up": ServiceIcon.FloorDrainBackingUp,
|
||||
"parking lot lighting": ServiceIcon.ParkingLotLighting,
|
||||
"mens restroom camera line": ServiceIcon.MensRestroomCameraLine,
|
||||
"assess window leaks": ServiceIcon.AssessWindowLeaks,
|
||||
"fire suppression system": ServiceIcon.FireSuppression,
|
||||
leak: ServiceIcon.Leak,
|
||||
};
|
||||
|
||||
/** Keyword heuristics for API Problem labels not in the exact map. */
|
||||
const SERVICE_KEYWORD_RULES: { test: RegExp; icon: ServiceIcon }[] = [
|
||||
{ test: /\b(fire|suppression)\b/i, icon: ServiceIcon.FireSuppression },
|
||||
{ test: /\b(camera|video)\b/i, icon: ServiceIcon.MensRestroomCameraLine },
|
||||
{ test: /\b(window)\b/i, icon: ServiceIcon.AssessWindowLeaks },
|
||||
{ test: /\b(leak|drain)\b/i, icon: ServiceIcon.Leak },
|
||||
{ test: /\b(backflow)\b/i, icon: ServiceIcon.BackflowPreventers },
|
||||
{ test: /\b(hvac|air)\b/i, icon: ServiceIcon.HvacInspection },
|
||||
{ test: /\b(electric)/i, icon: ServiceIcon.Electrical },
|
||||
{ test: /\b(plumb)/i, icon: ServiceIcon.Plumbing },
|
||||
{ test: /\b(door|hardware|lock)\b/i, icon: ServiceIcon.DoorHardware },
|
||||
{ test: /\b(light)/i, icon: ServiceIcon.ParkingLotLighting },
|
||||
{ test: /\b(pump)/i, icon: ServiceIcon.PumpingSystems },
|
||||
{ test: /\b(tank|heater|thermo)/i, icon: ServiceIcon.TankWaterHeater },
|
||||
{ test: /\b(valve|pressure|gauge)\b/i, icon: ServiceIcon.PressureReducingValve },
|
||||
{ test: /\b(scrubber|filter)\b/i, icon: ServiceIcon.ScrubberPit },
|
||||
{ test: /\b(broken|damaged|fault)\b/i, icon: ServiceIcon.AssessWindowLeaks },
|
||||
];
|
||||
|
||||
export function resolveServiceIcon(serviceLabel: string): ServiceIcon {
|
||||
const key = serviceLabel.trim().toLowerCase();
|
||||
if (!key) {
|
||||
return ServiceIcon.Default;
|
||||
}
|
||||
|
||||
const exact = SERVICE_LABEL_TO_ICON[key];
|
||||
if (exact) {
|
||||
return exact;
|
||||
}
|
||||
|
||||
for (const rule of SERVICE_KEYWORD_RULES) {
|
||||
if (rule.test.test(key)) {
|
||||
return rule.icon;
|
||||
}
|
||||
}
|
||||
|
||||
return ServiceIcon.Default;
|
||||
}
|
||||
|
||||
export function getPmTypeIcon(pmType: string): LucideIcon {
|
||||
return PM_TYPE_ICONS[pmType] ?? Wrench;
|
||||
return SERVICE_ICON_COMPONENTS[resolveServiceIcon(pmType)];
|
||||
}
|
||||
|
||||
type PmTypeIconProps = ComponentProps<LucideIcon> & { pmType: string };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import { toast, type ToastOptions } from "react-toastify";
|
||||
import { createElement } from "react";
|
||||
import { fmtDate, fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import { RescheduleToast } from "./reschedule-toast";
|
||||
|
||||
const TOAST_DURATION_MS = 6000;
|
||||
|
||||
const TOAST_OPTIONS: ToastOptions = {
|
||||
position: "top-center",
|
||||
autoClose: TOAST_DURATION_MS,
|
||||
hideProgressBar: true,
|
||||
closeButton: false,
|
||||
icon: false,
|
||||
className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
|
||||
};
|
||||
|
||||
type NotifyNavigate = {
|
||||
onNavigate: () => void;
|
||||
};
|
||||
|
||||
function showRescheduleToast(message: string, onNavigate: () => void) {
|
||||
const toastId = toast(
|
||||
createElement(RescheduleToast, {
|
||||
message,
|
||||
linkLabel: "Click to view it.",
|
||||
onNavigate: () => {
|
||||
toast.dismiss(toastId);
|
||||
onNavigate();
|
||||
},
|
||||
onDismiss: () => toast.dismiss(toastId),
|
||||
}),
|
||||
TOAST_OPTIONS,
|
||||
);
|
||||
return toastId;
|
||||
}
|
||||
|
||||
export function weekMoveNotify(weekStart: string, { onNavigate }: NotifyNavigate) {
|
||||
return showRescheduleToast(
|
||||
`Work order moved to Unscheduled · ${fmtWeekOf(weekStart)}.`,
|
||||
onNavigate,
|
||||
);
|
||||
}
|
||||
|
||||
export function rescheduleNotify(dateIso: string, { onNavigate }: NotifyNavigate) {
|
||||
return showRescheduleToast(`Work order rescheduled to ${fmtDate(dateIso)}.`, onNavigate);
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Calendar as CalendarIcon, X } from "lucide-react";
|
||||
|
||||
const TOAST_DURATION_MS = 6000;
|
||||
|
||||
type RescheduleToastProps = {
|
||||
message: string;
|
||||
linkLabel: string;
|
||||
onNavigate: () => void;
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
export function RescheduleToast({
|
||||
message,
|
||||
linkLabel,
|
||||
onNavigate,
|
||||
onDismiss,
|
||||
}: RescheduleToastProps) {
|
||||
const [w, setW] = useState(100);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setW(0), 30);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 360,
|
||||
background: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 8px 24px -8px rgba(38,34,98,.25)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2.5 p-3">
|
||||
<CalendarIcon size={16} className="mt-0.5 shrink-0" style={{ color: "var(--primary)" }} />
|
||||
<button type="button" onClick={onNavigate} className="flex-1 text-left hover:opacity-80">
|
||||
<p style={{ fontSize: 13 }}>
|
||||
{message} <span style={{ color: "var(--primary)", fontWeight: 500 }}>{linkLabel}</span>
|
||||
</p>
|
||||
</button>
|
||||
<button type="button" onClick={onDismiss} title="Dismiss" className="shrink-0">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 3,
|
||||
width: `${w}%`,
|
||||
background: "var(--primary)",
|
||||
transition: `width ${TOAST_DURATION_MS}ms linear`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
export type SitePlaceLookup = {
|
||||
city: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
export function buildSitePlaceLookups(sites: LocationOption[]): {
|
||||
byId: Map<string, SitePlaceLookup>;
|
||||
byCode: Map<string, SitePlaceLookup>;
|
||||
} {
|
||||
const byId = new Map<string, SitePlaceLookup>();
|
||||
const byCode = new Map<string, SitePlaceLookup>();
|
||||
for (const site of sites) {
|
||||
const place: SitePlaceLookup = {
|
||||
city: site.city ?? "",
|
||||
state: site.state ?? "",
|
||||
};
|
||||
if (!place.city && !place.state) {
|
||||
continue;
|
||||
}
|
||||
byId.set(String(site.id), place);
|
||||
const code = site.siteCode?.trim();
|
||||
if (code) {
|
||||
byCode.set(code.toUpperCase(), place);
|
||||
}
|
||||
}
|
||||
return { byId, byCode };
|
||||
}
|
||||
|
||||
export function resolveSitePlace(
|
||||
locationId: string | number | undefined,
|
||||
siteCode: string | undefined,
|
||||
lookups: { byId: Map<string, SitePlaceLookup>; byCode: Map<string, SitePlaceLookup> },
|
||||
): SitePlaceLookup | undefined {
|
||||
if (locationId !== undefined && locationId !== "") {
|
||||
const byId = lookups.byId.get(String(locationId));
|
||||
if (byId) {
|
||||
return byId;
|
||||
}
|
||||
}
|
||||
const code = siteCode?.trim();
|
||||
if (code) {
|
||||
return lookups.byCode.get(code.toUpperCase());
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { CircularProgress } from "@mui/material";
|
||||
import { Check, Flag, X } from "lucide-react";
|
||||
|
||||
import type {
|
||||
|
|
@ -39,12 +40,16 @@ type WoTableInlineRowProps = {
|
|||
|
||||
sites: LocationOption[];
|
||||
|
||||
sitePlace?: { city: string; state: string };
|
||||
|
||||
vendors: VendorDropdownItem[];
|
||||
|
||||
pmTypes: string[];
|
||||
|
||||
allRows: { id: string | number; woNumber: string }[];
|
||||
|
||||
saving?: boolean;
|
||||
|
||||
onChange: (draft: WorkOrderTableDraft) => void;
|
||||
|
||||
onSave: () => void;
|
||||
|
|
@ -61,12 +66,16 @@ export function WoTableInlineRow({
|
|||
|
||||
sites,
|
||||
|
||||
sitePlace,
|
||||
|
||||
vendors,
|
||||
|
||||
pmTypes,
|
||||
|
||||
allRows,
|
||||
|
||||
saving = false,
|
||||
|
||||
onChange,
|
||||
|
||||
onSave,
|
||||
|
|
@ -75,7 +84,8 @@ export function WoTableInlineRow({
|
|||
}: WoTableInlineRowProps) {
|
||||
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
|
||||
|
||||
const canSave = weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn;
|
||||
const canSave =
|
||||
!saving && (weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn);
|
||||
|
||||
const patch = (p: Partial<WorkOrderTableDraft>) => onChange({ ...draft, ...p });
|
||||
|
||||
|
|
@ -110,6 +120,10 @@ export function WoTableInlineRow({
|
|||
|
||||
pm: draft.pm,
|
||||
|
||||
serviceNotes: draft.serviceNotes,
|
||||
|
||||
extraServices: draft.extraServices,
|
||||
|
||||
company: draft.company,
|
||||
|
||||
tech: draft.tech,
|
||||
|
|
@ -119,6 +133,7 @@ export function WoTableInlineRow({
|
|||
|
||||
return (
|
||||
<tr
|
||||
aria-busy={saving || undefined}
|
||||
style={{
|
||||
height: WO_TABLE_ROW_HEIGHT,
|
||||
|
||||
|
|
@ -127,6 +142,8 @@ export function WoTableInlineRow({
|
|||
borderLeft: "2px solid var(--color-primary)",
|
||||
|
||||
background: "var(--color-primary-tint)",
|
||||
opacity: saving ? 0.7 : 1,
|
||||
pointerEvents: saving ? "none" : undefined,
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: "0 0 0 6px" }} />
|
||||
|
|
@ -140,6 +157,8 @@ export function WoTableInlineRow({
|
|||
value={draft.site}
|
||||
location={draft.location}
|
||||
locationId={draft.locationId}
|
||||
city={sitePlace?.city}
|
||||
state={sitePlace?.state}
|
||||
pocName={draft.pocName}
|
||||
pocPhone={draft.pocPhone}
|
||||
pocNotes={draft.pocNotes}
|
||||
|
|
@ -206,7 +225,21 @@ export function WoTableInlineRow({
|
|||
</td>
|
||||
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<PMTypeCell value={draft.pm} pmTypes={pmTypes} onSave={(pm) => patch({ pm })} />
|
||||
<PMTypeCell
|
||||
value={draft.pm}
|
||||
pmTypes={pmTypes}
|
||||
serviceNotes={draft.serviceNotes}
|
||||
extraServices={draft.extraServices}
|
||||
woNumber={draft.woNumber}
|
||||
onSave={(pm) => patch({ pm })}
|
||||
onSaveDetails={(d) =>
|
||||
patch({
|
||||
pm: d.pm,
|
||||
extraServices: d.extraServices,
|
||||
serviceNotes: d.serviceNotes,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
|
|
@ -215,7 +248,14 @@ export function WoTableInlineRow({
|
|||
tech={draft.tech}
|
||||
techPhone={draft.techPhone}
|
||||
vendors={vendors}
|
||||
onSave={(p) => patch({ company: p.company, tech: p.tech, techPhone: p.techPhone })}
|
||||
onSave={(p) =>
|
||||
patch({
|
||||
vendorId: p.vendorId,
|
||||
company: p.company,
|
||||
tech: p.tech,
|
||||
techPhone: p.techPhone,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
|
||||
|
|
@ -250,7 +290,8 @@ export function WoTableInlineRow({
|
|||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={!canSave}
|
||||
title={canSave ? "Save" : "Schedule On is required"}
|
||||
title={saving ? "Saving…" : canSave ? "Save" : "Schedule On is required"}
|
||||
aria-label={saving ? "Saving work order" : "Save work order"}
|
||||
className="rounded p-1 transition-colors disabled:opacity-30"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
onMouseEnter={(e) => {
|
||||
|
|
@ -263,14 +304,16 @@ export function WoTableInlineRow({
|
|||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<Check size={13} />
|
||||
{saving ? <CircularProgress size={13} color="inherit" /> : <Check size={13} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={saving}
|
||||
title="Cancel"
|
||||
className="rounded p-1 transition-colors"
|
||||
aria-label="Cancel draft"
|
||||
className="rounded p-1 transition-colors disabled:opacity-30"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-bg-muted)";
|
||||
|
|
|
|||
|
|
@ -6,10 +6,6 @@ import type {
|
|||
} from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
||||
import {
|
||||
applySchedulePatch,
|
||||
maybeAutoSchedulePatch,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||
import { ApptTimeCell } from "./cells/appt-time-cell";
|
||||
import { CompDocCell } from "./cells/comp-doc-cell";
|
||||
|
|
@ -31,6 +27,8 @@ export type WoTableRowHandlers = {
|
|||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => void;
|
||||
onScheduleDate: (id: string | number, date: string) => void;
|
||||
onScheduleWeek: (id: string | number, week: string) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
onOpenSlideOver: (row: WorkOrderTableRow) => void;
|
||||
onPastDueAttempt?: (id: string | number) => void;
|
||||
|
|
@ -39,7 +37,6 @@ export type WoTableRowHandlers = {
|
|||
onDragOver: (id: string | number) => void;
|
||||
onDrop: () => void;
|
||||
onDragEnd: () => void;
|
||||
setFlag: (id: string | number, color: string | null) => void;
|
||||
};
|
||||
|
||||
type WoTableRowProps = {
|
||||
|
|
@ -47,11 +44,11 @@ type WoTableRowProps = {
|
|||
allRows: WorkOrderTableRow[];
|
||||
users: WorkOrderUser[];
|
||||
sites: LocationOption[];
|
||||
sitePlace?: { city: string; state: string };
|
||||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
search?: string;
|
||||
forceScheduleHighlight?: boolean;
|
||||
flagColor?: string;
|
||||
isSelected: boolean;
|
||||
isFlashing: boolean;
|
||||
dragViz: DragVizState;
|
||||
|
|
@ -63,11 +60,11 @@ export function WoTableRow({
|
|||
allRows,
|
||||
users,
|
||||
sites,
|
||||
sitePlace,
|
||||
vendors,
|
||||
pmTypes,
|
||||
search,
|
||||
forceScheduleHighlight,
|
||||
flagColor,
|
||||
isSelected,
|
||||
isFlashing,
|
||||
dragViz,
|
||||
|
|
@ -84,13 +81,6 @@ export function WoTableRow({
|
|||
firstCellShadow = "inset 3px 0 0 var(--warning)";
|
||||
}
|
||||
|
||||
const patchAndMaybeAuto = (patch: Partial<WorkOrderTableRow>) => {
|
||||
if (isCanceled) return;
|
||||
const merged = { ...row, ...patch };
|
||||
const auto = maybeAutoSchedulePatch(merged);
|
||||
handlers.onPatch(row.id, { ...patch, ...auto });
|
||||
};
|
||||
|
||||
const patchRow = (patch: Partial<WorkOrderTableRow>) => {
|
||||
if (isCanceled) return;
|
||||
handlers.onPatch(row.id, patch);
|
||||
|
|
@ -162,9 +152,9 @@ export function WoTableRow({
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<FlagCell
|
||||
color={flagColor}
|
||||
color={row.flagColor}
|
||||
disabled={isCanceled}
|
||||
onChange={(c) => handlers.setFlag(row.id, c)}
|
||||
onChange={(c) => handlers.onPatch(row.id, { flagColor: c })}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
|
|
@ -176,6 +166,8 @@ export function WoTableRow({
|
|||
value={row.site}
|
||||
location={row.location}
|
||||
locationId={row.locationId}
|
||||
city={sitePlace?.city}
|
||||
state={sitePlace?.state}
|
||||
pocName={row.pocName}
|
||||
pocPhone={row.pocPhone}
|
||||
pocNotes={row.pocNotes}
|
||||
|
|
@ -221,7 +213,9 @@ export function WoTableRow({
|
|||
dispatcherId={row.dispatcherId}
|
||||
users={users}
|
||||
highlight={!row.dispatcherId}
|
||||
onSave={(id, name) => patchAndMaybeAuto({ dispatcherId: id, dispatcherName: name })}
|
||||
onSave={(id, name) =>
|
||||
handlers.onPatch(row.id, { dispatcherId: id, dispatcherName: name })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
|
|
@ -235,16 +229,8 @@ export function WoTableRow({
|
|||
highlight={forceScheduleHighlight || row.isPastDue}
|
||||
allowWeekMode
|
||||
targetWeek={row.targetWeek}
|
||||
onSave={(date) => patchAndMaybeAuto(applySchedulePatch(date, false))}
|
||||
onSaveWeek={(week) =>
|
||||
patchAndMaybeAuto({
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
})
|
||||
}
|
||||
onSave={(date) => handlers.onScheduleDate(row.id, date)}
|
||||
onSaveWeek={(week) => handlers.onScheduleWeek(row.id, week)}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
|
|
@ -259,7 +245,22 @@ export function WoTableRow({
|
|||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PMTypeCell value={row.pm} pmTypes={pmTypes} q={search} onSave={(pm) => patchRow({ pm })} />
|
||||
<PMTypeCell
|
||||
value={row.pm}
|
||||
pmTypes={pmTypes}
|
||||
q={search}
|
||||
serviceNotes={row.serviceNotes}
|
||||
extraServices={row.extraServices}
|
||||
woNumber={row.woNumber}
|
||||
onSave={(pm) => patchRow({ pm })}
|
||||
onSaveDetails={(d) =>
|
||||
patchRow({
|
||||
pm: d.pm,
|
||||
extraServices: d.extraServices,
|
||||
serviceNotes: d.serviceNotes,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className="group/cell"
|
||||
|
|
@ -274,6 +275,7 @@ export function WoTableRow({
|
|||
q={search}
|
||||
onSave={(p) =>
|
||||
patchRow({
|
||||
vendorId: p.vendorId,
|
||||
company: p.company,
|
||||
tech: p.tech,
|
||||
techPhone: p.techPhone,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,26 @@
|
|||
import { Fragment, useMemo, useState } from "react";
|
||||
import { CircularProgress } from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { SearchX } from "lucide-react";
|
||||
import { useLocationSites } from "@/domain/locations/use-cases/use-location-sites";
|
||||
import type {
|
||||
LocationOption,
|
||||
VendorDropdownItem,
|
||||
WorkOrderUser,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type {
|
||||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { mapWizardDraftToFormValues } from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
||||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
|
||||
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify-cross-week";
|
||||
import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
|
||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||
import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify";
|
||||
import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup";
|
||||
import { WoTableHead } from "./wo-table-head";
|
||||
import { WoTableInlineRow } from "./wo-table-inline-row";
|
||||
import { WoTableRow } from "./wo-table-row";
|
||||
|
|
@ -32,12 +40,15 @@ type WoTableProps = {
|
|||
sites: LocationOption[];
|
||||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
weekMonday: string;
|
||||
onWeekChange: (mondayIso: string) => void;
|
||||
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
||||
/** Omit until completion-doc slice mounts CompDocDialog. */
|
||||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||
onClearSearch?: () => void;
|
||||
onOpenAdvancedFilters?: () => void;
|
||||
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
|
||||
};
|
||||
|
||||
function WoTableShell({ children }: { children: React.ReactNode }) {
|
||||
|
|
@ -88,18 +99,29 @@ export function WoTable({
|
|||
sites,
|
||||
vendors,
|
||||
pmTypes,
|
||||
weekMonday,
|
||||
onWeekChange,
|
||||
onOpenSlideOver,
|
||||
onOpenCompDoc,
|
||||
onOpenMedia,
|
||||
onClearSearch,
|
||||
onOpenAdvancedFilters,
|
||||
onPatch,
|
||||
}: WoTableProps) {
|
||||
const [scheduleHighlightId, setScheduleHighlightId] = useState<string | number | null>(null);
|
||||
const { patchField } = useWorkOrderTableMutations(tableData.patchRow);
|
||||
const { data: locationSites = [] } = useLocationSites();
|
||||
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
|
||||
const createMutation = useCreateWorkOrderFromWizard(() => {
|
||||
tableData.cancelDraft();
|
||||
});
|
||||
const { dragState: dragStateRef, setDragViz, reorderRows, endDrag, setFlag } = tableData;
|
||||
const {
|
||||
dragState: dragStateRef,
|
||||
setDragViz,
|
||||
reorderRows,
|
||||
endDrag,
|
||||
setUnschedOpen,
|
||||
triggerFlash,
|
||||
} = tableData;
|
||||
|
||||
const handlers = useMemo(
|
||||
() => ({
|
||||
|
|
@ -126,7 +148,36 @@ export function WoTable({
|
|||
onOpenMedia(row);
|
||||
},
|
||||
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => {
|
||||
patchField(id, patch);
|
||||
onPatch(id, patch);
|
||||
},
|
||||
onScheduleDate: (id: string | number, date: string) => {
|
||||
onPatch(id, applySchedulePatch(date, false));
|
||||
if (shouldNotifyCrossWeek(date, weekMonday)) {
|
||||
const targetMonday = mondayOf(date);
|
||||
rescheduleNotify(date, {
|
||||
onNavigate: () => {
|
||||
onWeekChange(targetMonday);
|
||||
setTimeout(() => triggerFlash(id), 180);
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
onScheduleWeek: (id: string | number, week: string) => {
|
||||
onPatch(id, {
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
});
|
||||
setUnschedOpen(true);
|
||||
triggerFlash(id);
|
||||
weekMoveNotify(week, {
|
||||
onNavigate: () => {
|
||||
setUnschedOpen(true);
|
||||
setTimeout(() => triggerFlash(id), 180);
|
||||
},
|
||||
});
|
||||
},
|
||||
onDuplicateFound: (existing: WorkOrderTableRow) => {
|
||||
tableData.setSelectedId(existing.id);
|
||||
|
|
@ -154,7 +205,6 @@ export function WoTable({
|
|||
endDrag();
|
||||
},
|
||||
onDragEnd: endDrag,
|
||||
setFlag,
|
||||
}),
|
||||
[
|
||||
tableData,
|
||||
|
|
@ -162,11 +212,14 @@ export function WoTable({
|
|||
setDragViz,
|
||||
reorderRows,
|
||||
endDrag,
|
||||
setFlag,
|
||||
setUnschedOpen,
|
||||
triggerFlash,
|
||||
weekMonday,
|
||||
onWeekChange,
|
||||
onOpenSlideOver,
|
||||
onOpenCompDoc,
|
||||
onOpenMedia,
|
||||
patchField,
|
||||
onPatch,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -177,11 +230,11 @@ export function WoTable({
|
|||
allRows={tableData.rows}
|
||||
users={users}
|
||||
sites={sites}
|
||||
sitePlace={resolveSitePlace(row.locationId, row.site, sitePlaceLookups)}
|
||||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
search={tableData.search}
|
||||
forceScheduleHighlight={forceScheduleHighlight || scheduleHighlightId === row.id}
|
||||
flagColor={tableData.flags[String(row.id)]}
|
||||
isSelected={tableData.selectedId === row.id}
|
||||
isFlashing={tableData.flashId === row.id}
|
||||
dragViz={tableData.dragViz}
|
||||
|
|
@ -192,9 +245,12 @@ export function WoTable({
|
|||
const handleSaveDraft = () => {
|
||||
const d = tableData.draft;
|
||||
const assignee = users.find((u) => String(u.id) === d.dispatcherId);
|
||||
const formValues = mapWizardDraftToFormValues({
|
||||
const location = sites.find((s) => String(s.id) === String(d.locationId));
|
||||
const locationName = d.location || d.site || location?.name || "";
|
||||
createMutation.mutate({
|
||||
locationId: String(d.locationId || ""),
|
||||
locationName: d.location || d.site,
|
||||
locationName,
|
||||
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
|
||||
woNumber: d.woNumber,
|
||||
type: d.type,
|
||||
assigneeId: d.dispatcherId,
|
||||
|
|
@ -202,7 +258,9 @@ export function WoTable({
|
|||
scheduledOn: d.scheduledOn,
|
||||
dueDate: d.dueDate,
|
||||
pm: d.pm,
|
||||
vendorId: "",
|
||||
extraServices: d.extraServices ?? [],
|
||||
pmNote: d.serviceNotes ?? "",
|
||||
vendorId: d.vendorId || "",
|
||||
vendorName: d.company,
|
||||
techPhone: d.techPhone,
|
||||
vendorNotes: "",
|
||||
|
|
@ -213,16 +271,14 @@ export function WoTable({
|
|||
pocPhone: d.pocPhone,
|
||||
pocNotes: d.pocNotes,
|
||||
scheduleWeekOnly: d.scheduleWeekOnly,
|
||||
targetWeek: d.targetWeek || "",
|
||||
});
|
||||
createMutation.mutate(formValues);
|
||||
};
|
||||
|
||||
if (tableData.isLoading) {
|
||||
return (
|
||||
<WoTableShell>
|
||||
<div className="flex h-full items-center justify-center p-12">
|
||||
<CircularProgress />
|
||||
</div>
|
||||
<WoTableSkeleton weekMonday={weekMonday} />
|
||||
</WoTableShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -287,9 +343,15 @@ export function WoTable({
|
|||
draftDayKey={tableData.draftDayKey}
|
||||
users={users}
|
||||
sites={sites}
|
||||
sitePlace={resolveSitePlace(
|
||||
tableData.draft.locationId,
|
||||
tableData.draft.site,
|
||||
sitePlaceLookups,
|
||||
)}
|
||||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
allRows={tableData.rows}
|
||||
saving={createMutation.isPending}
|
||||
onChange={tableData.setDraft}
|
||||
onSave={handleSaveDraft}
|
||||
onCancel={tableData.cancelDraft}
|
||||
|
|
@ -307,7 +369,7 @@ export function WoTable({
|
|||
label={group.label}
|
||||
count={group.items.length}
|
||||
isToday={group.key === tableData.todayKey}
|
||||
onAddWo={() => tableData.startDayDraft(group.key)}
|
||||
onAddWo={() => tableData.startDayDraft(group.key, group.date)}
|
||||
/>
|
||||
{tableData.draftDayKey === group.key && (
|
||||
<WoTableInlineRow
|
||||
|
|
@ -315,9 +377,15 @@ export function WoTable({
|
|||
draftDayKey={tableData.draftDayKey}
|
||||
users={users}
|
||||
sites={sites}
|
||||
sitePlace={resolveSitePlace(
|
||||
tableData.draft.locationId,
|
||||
tableData.draft.site,
|
||||
sitePlaceLookups,
|
||||
)}
|
||||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
allRows={tableData.rows}
|
||||
saving={createMutation.isPending}
|
||||
onChange={tableData.setDraft}
|
||||
onSave={handleSaveDraft}
|
||||
onCancel={tableData.cancelDraft}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,9 @@ export function WorkOrdersListHeader({
|
|||
</ToolbarButton>
|
||||
)}
|
||||
|
||||
{isFetching && <CircularProgress size={14} />}
|
||||
{isFetching && (
|
||||
<CircularProgress size={14} aria-label="Refreshing work orders" role="status" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
|
||||
import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list";
|
||||
import { WORK_ORDERS_TABLE_PAGE_SIZE } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import type {
|
||||
WorkOrderBarFilters,
|
||||
WorkOrderTypeFilter,
|
||||
} from "@/domain/work-orders/types/work-order-list-filters";
|
||||
import type { WorkOrderBoardQueryParams } from "@/domain/work-orders/types/work-order-board";
|
||||
import {
|
||||
clearAdvancedFilterGroup,
|
||||
cloneAdvancedFilters,
|
||||
|
|
@ -15,46 +13,21 @@ import {
|
|||
getInitialAdvancedFilters,
|
||||
} from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||
import {
|
||||
compactWeekLabel,
|
||||
currentWeekMonday,
|
||||
weekRange,
|
||||
} from "@/domain/work-orders/utils/list-week-utils";
|
||||
BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||
toBoardQueryParams,
|
||||
} from "@/domain/work-orders/utils/board-query-params";
|
||||
import { compactWeekLabel, currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||
|
||||
function defaultDispatcherIds(currentUserId?: string | number): Set<string> {
|
||||
if (isWoTableMockEnabled()) {
|
||||
return new Set();
|
||||
}
|
||||
if (currentUserId != null && currentUserId !== "") {
|
||||
return new Set([String(currentUserId)]);
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
/** Phase 2: map bar filters to API list params. */
|
||||
export function toListParamsFromBar(
|
||||
bar: WorkOrderBarFilters,
|
||||
): Pick<WorkOrdersListParams, "dateFrom" | "dateTo" | "type" | "assigneeIds"> {
|
||||
const [dateFrom, dateTo] = weekRange(bar.weekMonday);
|
||||
return {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
type: bar.type === "All" ? undefined : bar.type,
|
||||
assigneeIds: bar.dispatcherIds.size > 0 ? [...bar.dispatcherIds].join(",") : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function useWorkOrdersListFilters(currentUserId?: string | number) {
|
||||
export function useWorkOrdersListFilters() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [advOpen, setAdvOpen] = useState(false);
|
||||
const [advApplied, setAdvApplied] = useState<WorkOrderAdvancedFilters | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(12);
|
||||
const [pageSize, setPageSize] = useState(BOARD_SEARCH_MAX_PAGE_SIZE);
|
||||
const [sortBy, setSortBy] = useState("");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
|
||||
const [weekMonday, setWeekMondayState] = useState(currentWeekMonday);
|
||||
const [dispatcherIds, setDispatcherIdsState] = useState<Set<string>>(() =>
|
||||
defaultDispatcherIds(currentUserId),
|
||||
);
|
||||
const [dispatcherIds, setDispatcherIdsState] = useState<Set<string>>(() => new Set());
|
||||
const [typeFilter, setTypeFilterState] = useState<WorkOrderTypeFilter>("All");
|
||||
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
|
@ -70,25 +43,9 @@ export function useWorkOrdersListFilters(currentUserId?: string | number) {
|
|||
|
||||
const weekLabel = useMemo(() => compactWeekLabel(weekMonday), [weekMonday]);
|
||||
|
||||
const scopeListParams = useMemo<WorkOrdersListParams>(
|
||||
() => ({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
...toListParamsFromBar(barFilters),
|
||||
}),
|
||||
[barFilters],
|
||||
);
|
||||
|
||||
const listParams = useMemo<WorkOrdersListParams>(
|
||||
() => ({
|
||||
search: debouncedSearch,
|
||||
page: 1,
|
||||
pageSize: WORK_ORDERS_TABLE_PAGE_SIZE,
|
||||
sortBy,
|
||||
sortDir,
|
||||
...toListParamsFromBar(barFilters),
|
||||
}),
|
||||
[debouncedSearch, sortBy, sortDir, barFilters],
|
||||
const boardParams = useMemo<WorkOrderBoardQueryParams>(
|
||||
() => toBoardQueryParams(barFilters, debouncedSearch),
|
||||
[barFilters, debouncedSearch],
|
||||
);
|
||||
|
||||
const activeFilterCount = useMemo(
|
||||
|
|
@ -170,8 +127,7 @@ export function useWorkOrdersListFilters(currentUserId?: string | number) {
|
|||
typeFilter,
|
||||
barFilters,
|
||||
weekLabel,
|
||||
listParams,
|
||||
scopeListParams,
|
||||
boardParams,
|
||||
activeFilterCount,
|
||||
handleSearchChange,
|
||||
applyAdvancedFilters,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||
import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import {
|
||||
useWorkOrdersBoard,
|
||||
useWorkOrdersBoardSearch,
|
||||
} from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import {
|
||||
useWorkOrderLocations,
|
||||
useWorkOrderVendors,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
EMPTY_TABLE_DRAFT,
|
||||
UNSCHEDULED_DAY_KEY,
|
||||
|
|
@ -13,9 +20,23 @@ import {
|
|||
buildDayGroups,
|
||||
enrichTableRow,
|
||||
filterByBarAndSearch,
|
||||
matchesAdvancedFilters,
|
||||
splitScheduledUnscheduled,
|
||||
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||
import {
|
||||
BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||
toBoardSearchQueryParams,
|
||||
} from "@/domain/work-orders/utils/board-query-params";
|
||||
import {
|
||||
applyRowOrderByDay,
|
||||
loadBoardRowOrder,
|
||||
mergeDayOrder,
|
||||
removeIdFromRowOrder,
|
||||
reorderIdsInList,
|
||||
saveBoardRowOrder,
|
||||
type BoardRowOrderByDay,
|
||||
} from "@/domain/work-orders/utils/board-row-order-storage";
|
||||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import type { WorkOrderBoardSearchQueryParams } from "@/domain/work-orders/types/work-order-board";
|
||||
|
||||
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
||||
|
||||
|
|
@ -25,13 +46,89 @@ export type DragVizState = {
|
|||
};
|
||||
|
||||
export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||
const { data, isLoading, isFetching } = useWorkOrdersList(filters.listParams);
|
||||
const { data: scopeData } = useWorkOrdersList(filters.scopeListParams);
|
||||
const advActive = filters.advApplied !== null;
|
||||
const { data: locations = [] } = useWorkOrderLocations();
|
||||
const { data: vendors = [] } = useWorkOrderVendors();
|
||||
|
||||
const resolvedSearchParams = useMemo<WorkOrderBoardSearchQueryParams | null>(() => {
|
||||
if (!filters.advApplied) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const siteCodes = new Set<string>();
|
||||
for (const siteKey of filters.advApplied.sites) {
|
||||
const location = locations.find((item) => String(item.id) === siteKey);
|
||||
if (location) {
|
||||
const code = resolveSiteCode({
|
||||
siteCode: location.siteCode,
|
||||
name: location.name,
|
||||
});
|
||||
if (code) {
|
||||
siteCodes.add(code);
|
||||
}
|
||||
} else if (siteKey.trim()) {
|
||||
siteCodes.add(siteKey);
|
||||
}
|
||||
}
|
||||
|
||||
const vendorIdsByCompanyName = new Map<string, number>();
|
||||
for (const vendor of vendors) {
|
||||
const id = Number(vendor.id);
|
||||
if (Number.isFinite(id) && vendor.companyName) {
|
||||
vendorIdsByCompanyName.set(vendor.companyName, id);
|
||||
}
|
||||
}
|
||||
|
||||
return toBoardSearchQueryParams({
|
||||
filters: {
|
||||
...filters.advApplied,
|
||||
sites: siteCodes,
|
||||
},
|
||||
search: filters.search,
|
||||
page: filters.page,
|
||||
pageSize: Math.min(
|
||||
filters.pageSize || BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||
BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||
),
|
||||
sortBy: filters.sortBy || "scheduledDate",
|
||||
sortDir: filters.sortDir || "asc",
|
||||
vendorIdsByCompanyName,
|
||||
});
|
||||
}, [
|
||||
filters.advApplied,
|
||||
filters.search,
|
||||
filters.page,
|
||||
filters.pageSize,
|
||||
filters.sortBy,
|
||||
filters.sortDir,
|
||||
locations,
|
||||
vendors,
|
||||
]);
|
||||
|
||||
const {
|
||||
data: boardData,
|
||||
isLoading: boardLoading,
|
||||
isFetching: boardFetching,
|
||||
error: boardError,
|
||||
refetch: refetchBoard,
|
||||
} = useWorkOrdersBoard(filters.boardParams, !advActive);
|
||||
const {
|
||||
data: searchData,
|
||||
isLoading: searchLoading,
|
||||
isFetching: searchFetching,
|
||||
error: searchError,
|
||||
refetch: refetchSearch,
|
||||
} = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive);
|
||||
|
||||
const data = advActive ? searchData : boardData;
|
||||
const isLoading = advActive ? searchLoading : boardLoading;
|
||||
const isFetching = advActive ? searchFetching : boardFetching;
|
||||
const error = advActive ? searchError : boardError;
|
||||
const refetch = advActive ? refetchSearch : refetchBoard;
|
||||
|
||||
const [patches, setPatches] = useState<Record<string, Partial<WorkOrderTableRow>>>({});
|
||||
const [localRows, setLocalRows] = useState<WorkOrderTableRow[]>([]);
|
||||
const [rowOrder, setRowOrder] = useState<string[] | null>(null);
|
||||
const [flags, setFlagsState] = useState<Record<string, string>>({});
|
||||
const [rowOrderByDay, setRowOrderByDay] = useState<BoardRowOrderByDay>({});
|
||||
const [draftDayKey, setDraftDayKey] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<WorkOrderTableDraft>(EMPTY_TABLE_DRAFT);
|
||||
const [unschedOpen, setUnschedOpen] = useState(true);
|
||||
|
|
@ -40,6 +137,10 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
const [dragViz, setDragViz] = useState<DragVizState>({ dragId: null, overId: null });
|
||||
const dragState = useRef<DragVizState>({ dragId: null, overId: null });
|
||||
|
||||
useEffect(() => {
|
||||
setRowOrderByDay(loadBoardRowOrder(filters.weekMonday));
|
||||
}, [filters.weekMonday]);
|
||||
|
||||
const apiRows = useMemo(
|
||||
() => (data?.items ?? []).map((row) => enrichTableRow({ ...row, ...patches[String(row.id)] })),
|
||||
[data?.items, patches],
|
||||
|
|
@ -48,41 +149,41 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
const displayRows = useMemo(() => {
|
||||
const apiIds = new Set(apiRows.map((r) => String(r.id)));
|
||||
const extra = localRows.filter((r) => !apiIds.has(String(r.id)));
|
||||
let merged = [...apiRows, ...extra.map(enrichTableRow)];
|
||||
if (rowOrder?.length) {
|
||||
const orderMap = new Map(rowOrder.map((id, idx) => [id, idx]));
|
||||
merged = [...merged].sort((a, b) => {
|
||||
const ai = orderMap.get(String(a.id)) ?? 9999;
|
||||
const bi = orderMap.get(String(b.id)) ?? 9999;
|
||||
return ai - bi;
|
||||
});
|
||||
}
|
||||
return merged;
|
||||
}, [apiRows, localRows, rowOrder]);
|
||||
const merged = [...apiRows, ...extra.map(enrichTableRow)];
|
||||
return applyRowOrderByDay(merged, rowOrderByDay);
|
||||
}, [apiRows, localRows, rowOrderByDay]);
|
||||
|
||||
const advActive = filters.advApplied !== null;
|
||||
const advResults = useMemo(() => {
|
||||
if (!filters.advApplied) {
|
||||
if (!advActive) {
|
||||
return [];
|
||||
}
|
||||
return displayRows.filter((row) => matchesAdvancedFilters(row, filters.advApplied!));
|
||||
}, [displayRows, filters.advApplied]);
|
||||
return displayRows;
|
||||
}, [advActive, displayRows]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (advActive) {
|
||||
return [];
|
||||
}
|
||||
return filterByBarAndSearch(displayRows, filters.barFilters, filters.search);
|
||||
if (filters.search.trim().length > 0 && filters.search.trim().length < 2) {
|
||||
return filterByBarAndSearch(displayRows, filters.barFilters, filters.search);
|
||||
}
|
||||
return displayRows;
|
||||
}, [displayRows, filters.barFilters, filters.search, advActive]);
|
||||
|
||||
const { scheduled, unscheduled } = useMemo(() => splitScheduledUnscheduled(filtered), [filtered]);
|
||||
const { scheduled, unscheduled } = useMemo(() => {
|
||||
if (advActive) {
|
||||
return splitScheduledUnscheduled(advResults);
|
||||
}
|
||||
return splitScheduledUnscheduled(filtered);
|
||||
}, [advActive, advResults, filtered]);
|
||||
|
||||
const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]);
|
||||
const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]);
|
||||
|
||||
const searchActive = filters.search.trim() !== "";
|
||||
const noSearchResults = filtered.length === 0 && searchActive;
|
||||
const isEmptyWeek = scheduled.length === 0 && !searchActive;
|
||||
const visibleRows = advActive ? advResults : filtered;
|
||||
const noSearchResults = visibleRows.length === 0 && searchActive;
|
||||
const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
|
||||
const displayGroups = isEmptyWeek ? weekSkeleton : dayGroups;
|
||||
|
||||
const todayKey =
|
||||
|
|
@ -95,33 +196,47 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
[displayRows, selectedId],
|
||||
);
|
||||
|
||||
const setFlag = useCallback((id: string | number, color: string | null) => {
|
||||
setFlagsState((prev) => {
|
||||
const patchRow = useCallback(
|
||||
(id: string | number, patch: Partial<WorkOrderTableRow>) => {
|
||||
const key = String(id);
|
||||
if (!color) {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
}
|
||||
return { ...prev, [key]: color };
|
||||
});
|
||||
}, []);
|
||||
const previousRow =
|
||||
displayRows.find((row) => String(row.id) === key) ??
|
||||
localRows.find((row) => String(row.id) === key);
|
||||
const previousDayGroup = previousRow?.dayGroup;
|
||||
|
||||
const patchRow = useCallback((id: string | number, patch: Partial<WorkOrderTableRow>) => {
|
||||
const key = String(id);
|
||||
setPatches((prev) => {
|
||||
const merged = { ...prev[key], ...patch };
|
||||
if (patch.scheduledOn !== undefined) {
|
||||
const dayFields = deriveDayFields(patch.scheduledOn);
|
||||
merged.dayGroup = dayFields.dayGroup;
|
||||
merged.dayLabel = dayFields.dayLabel;
|
||||
setPatches((prev) => {
|
||||
const merged = { ...prev[key], ...patch };
|
||||
if (patch.scheduledOn !== undefined) {
|
||||
const dayFields = deriveDayFields(patch.scheduledOn);
|
||||
merged.dayGroup = dayFields.dayGroup;
|
||||
merged.dayLabel = dayFields.dayLabel;
|
||||
}
|
||||
return { ...prev, [key]: merged };
|
||||
});
|
||||
setLocalRows((prev) =>
|
||||
prev.map((row) => (String(row.id) === key ? enrichTableRow({ ...row, ...patch }) : row)),
|
||||
);
|
||||
|
||||
if (patch.scheduledOn === undefined || advActive) {
|
||||
return;
|
||||
}
|
||||
return { ...prev, [key]: merged };
|
||||
});
|
||||
setLocalRows((prev) =>
|
||||
prev.map((row) => (String(row.id) === key ? enrichTableRow({ ...row, ...patch }) : row)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const nextDayGroup = deriveDayFields(patch.scheduledOn).dayGroup;
|
||||
if (!previousDayGroup || previousDayGroup === nextDayGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRowOrderByDay((prev) => {
|
||||
const next = removeIdFromRowOrder(prev, key);
|
||||
if (next === prev) {
|
||||
return prev;
|
||||
}
|
||||
saveBoardRowOrder(filters.weekMonday, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[advActive, displayRows, filters.weekMonday, localRows],
|
||||
);
|
||||
|
||||
const reorderRows = useCallback(
|
||||
(dragId: string | number, overId: string | number) => {
|
||||
|
|
@ -132,18 +247,26 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
if (!dragRow || !overRow || dragRow.dayGroup !== overRow.dayGroup) {
|
||||
return;
|
||||
}
|
||||
const ids = displayRows.map((r) => String(r.id));
|
||||
const dragIdx = ids.indexOf(dragKey);
|
||||
const overIdx = ids.indexOf(overKey);
|
||||
if (dragIdx < 0 || overIdx < 0) {
|
||||
|
||||
const dayGroup = dragRow.dayGroup;
|
||||
const dayIds = displayRows
|
||||
.filter((row) => row.dayGroup === dayGroup)
|
||||
.map((row) => String(row.id));
|
||||
const reorderedVisible = reorderIdsInList(dayIds, dragKey, overKey);
|
||||
if (!reorderedVisible) {
|
||||
return;
|
||||
}
|
||||
const next = [...ids];
|
||||
next.splice(dragIdx, 1);
|
||||
next.splice(overIdx, 0, dragKey);
|
||||
setRowOrder(next);
|
||||
|
||||
setRowOrderByDay((prev) => {
|
||||
const nextDayIds = mergeDayOrder(prev[dayGroup], reorderedVisible);
|
||||
const next = { ...prev, [dayGroup]: nextDayIds };
|
||||
if (!advActive) {
|
||||
saveBoardRowOrder(filters.weekMonday, next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[displayRows],
|
||||
[advActive, displayRows, filters.weekMonday],
|
||||
);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
|
|
@ -152,16 +275,26 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
}, []);
|
||||
|
||||
const startDayDraft = useCallback(
|
||||
(key: string) => {
|
||||
(key: string, scheduledIso?: string) => {
|
||||
setDraftDayKey(key);
|
||||
const scheduledOn =
|
||||
key === UNSCHEDULED_DAY_KEY
|
||||
? ""
|
||||
: (() => {
|
||||
const [mm, dd] = key.split("/").map(Number);
|
||||
const year = filters.weekMonday.slice(0, 4);
|
||||
return `${year}-${String(mm).padStart(2, "0")}-${String(dd).padStart(2, "0")}`;
|
||||
})();
|
||||
: scheduledIso && /^\d{4}-\d{2}-\d{2}$/.test(scheduledIso)
|
||||
? scheduledIso
|
||||
: (() => {
|
||||
const base = new Date(`${filters.weekMonday}T12:00:00`);
|
||||
for (let i = 0; i < 7; i += 1) {
|
||||
const d = new Date(base);
|
||||
d.setDate(base.getDate() + i);
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
if (`${mm}/${dd}` === key) {
|
||||
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
})();
|
||||
setDraft({
|
||||
...EMPTY_TABLE_DRAFT,
|
||||
scheduledOn,
|
||||
|
|
@ -177,16 +310,30 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
setDraft(EMPTY_TABLE_DRAFT);
|
||||
}, []);
|
||||
|
||||
const addRow = useCallback((row: WorkOrderTableRow) => {
|
||||
setLocalRows((prev) => [...prev, enrichTableRow(row)]);
|
||||
setFlashId(row.id);
|
||||
setTimeout(() => setFlashId(null), 2000);
|
||||
const triggerFlash = useCallback((id: string | number) => {
|
||||
setFlashId(id);
|
||||
setTimeout(() => {
|
||||
document
|
||||
.getElementById(`wo-row-${id}`)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 80);
|
||||
setTimeout(() => setFlashId(null), 2500);
|
||||
}, []);
|
||||
|
||||
const addRow = useCallback(
|
||||
(row: WorkOrderTableRow) => {
|
||||
setLocalRows((prev) => [...prev, enrichTableRow(row)]);
|
||||
triggerFlash(row.id);
|
||||
},
|
||||
[triggerFlash],
|
||||
);
|
||||
|
||||
return {
|
||||
rows: displayRows,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
advActive,
|
||||
advResults,
|
||||
unscheduled,
|
||||
|
|
@ -195,8 +342,6 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
noSearchResults,
|
||||
isEmptyWeek,
|
||||
todayKey,
|
||||
flags,
|
||||
setFlag,
|
||||
draftDayKey,
|
||||
draft,
|
||||
setDraft,
|
||||
|
|
@ -206,6 +351,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
setSelectedId,
|
||||
selectedRow,
|
||||
flashId,
|
||||
triggerFlash,
|
||||
dragViz,
|
||||
setDragViz,
|
||||
dragState,
|
||||
|
|
@ -215,8 +361,10 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
startDayDraft,
|
||||
cancelDraft,
|
||||
addRow,
|
||||
scopeTotalCount: scopeData?.totalCount ?? 0,
|
||||
totalCount: data?.totalCount ?? displayRows.length,
|
||||
scopeTotalCount: data?.totalCount ?? 0,
|
||||
totalCount: advActive
|
||||
? (data?.totalCount ?? advResults.length)
|
||||
: (data?.returnedCount ?? visibleRows.length),
|
||||
search: filters.search,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
|||
import { Box } from "@mui/material";
|
||||
import { useNavigate } from "react-router";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
|
||||
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
|
||||
|
|
@ -17,8 +18,6 @@ import {
|
|||
useWorkOrderVendors,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
|
||||
import { getMockPmTypes } from "@/domain/work-orders/mocks/wo-table-mock-data";
|
||||
import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
|
|
@ -30,20 +29,20 @@ export default function WorkOrdersListPage() {
|
|||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
|
||||
const [mediaFiles, setMediaFiles] = useState<Record<string, MediaFile[]>>({});
|
||||
const filters = useWorkOrdersListFilters(user?.id);
|
||||
const filters = useWorkOrdersListFilters();
|
||||
const tableData = useWorkOrdersTableData(filters);
|
||||
const { patchField } = useWorkOrderTableMutations(tableData.patchRow);
|
||||
const { patchField } = useWorkOrderTableMutations(
|
||||
tableData.patchRow,
|
||||
(id) => tableData.rows.find((row) => String(row.id) === String(id)),
|
||||
);
|
||||
|
||||
const { data: users = [] } = useWorkOrderUsers();
|
||||
const { data: users = [], isLoading: usersLoading } = useWorkOrderUsers();
|
||||
const { data: locations = [] } = useWorkOrderLocations();
|
||||
const { data: vendors = [] } = useWorkOrderVendors();
|
||||
const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
|
||||
|
||||
const pmTypes = useMemo(
|
||||
() =>
|
||||
isWoTableMockEnabled()
|
||||
? getMockPmTypes()
|
||||
: problemOptions.map((option) => option.value).filter(Boolean),
|
||||
() => problemOptions.map((option) => option.value).filter(Boolean),
|
||||
[problemOptions],
|
||||
);
|
||||
|
||||
|
|
@ -86,6 +85,7 @@ export default function WorkOrdersListPage() {
|
|||
typeFilter={filters.typeFilter}
|
||||
onTypeChange={filters.setTypeFilter}
|
||||
users={users}
|
||||
usersLoading={usersLoading}
|
||||
currentUserId={user?.id}
|
||||
filteredCount={totalCount}
|
||||
scopeTotalCount={scopeTotalCount}
|
||||
|
|
@ -102,12 +102,27 @@ export default function WorkOrdersListPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{tableData.error && (
|
||||
<QueryErrorPanel
|
||||
className="mb-2 shrink-0"
|
||||
compact
|
||||
message={
|
||||
tableData.error instanceof Error
|
||||
? tableData.error.message
|
||||
: "Failed to load work orders."
|
||||
}
|
||||
onRetry={() => void tableData.refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<WoTable
|
||||
tableData={tableData}
|
||||
users={users}
|
||||
sites={locations}
|
||||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
weekMonday={filters.weekMonday}
|
||||
onWeekChange={filters.setWeekMonday}
|
||||
onOpenSlideOver={(row, edit) => {
|
||||
tableData.setSelectedId(row.id);
|
||||
navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`);
|
||||
|
|
@ -115,6 +130,7 @@ export default function WorkOrdersListPage() {
|
|||
onOpenMedia={setMediaRow}
|
||||
onClearSearch={() => filters.handleSearchChange("")}
|
||||
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||
onPatch={patchField}
|
||||
/>
|
||||
|
||||
<WorkOrdersAdvancedSearchSheet
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-res
|
|||
import {
|
||||
mapLocation,
|
||||
mapLocationListItems,
|
||||
mapLocationSites,
|
||||
mapLocationToBackend,
|
||||
} from "@/domain/locations/mappers/location-mapper";
|
||||
import type {
|
||||
Location,
|
||||
LocationSite,
|
||||
LocationsListParams,
|
||||
LocationsListResult,
|
||||
} from "@/domain/locations/types/location";
|
||||
|
|
@ -42,6 +44,14 @@ export const locationsApi = {
|
|||
return mapLocation(handleApiResponse(data));
|
||||
},
|
||||
|
||||
getSites: async (search?: string): Promise<LocationSite[]> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.location.sites, {
|
||||
searchParams: search?.trim() ? { search: search.trim() } : undefined,
|
||||
});
|
||||
const items = handleApiResponse<unknown[]>(data);
|
||||
return Array.isArray(items) ? mapLocationSites(items) : [];
|
||||
},
|
||||
|
||||
create: async (payload: unknown): Promise<Location> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.rest.locations, mapLocationToBackend(payload));
|
||||
return mapLocation(handleApiResponse(data));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Location, LocationListItem } from "@/domain/locations/types/location";
|
||||
import type { Location, LocationListItem, LocationSite } from "@/domain/locations/types/location";
|
||||
|
||||
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
|
|
@ -29,6 +29,67 @@ function readOptionalId(record: Record<string, unknown>): string | number {
|
|||
return "";
|
||||
}
|
||||
|
||||
export function formatLocationSiteLabel(
|
||||
site: Pick<LocationSite, "code" | "city" | "state">,
|
||||
): string {
|
||||
const place = [site.city, site.state].filter(Boolean).join(", ");
|
||||
if (site.code && place) {
|
||||
return `${site.code} · ${place}`;
|
||||
}
|
||||
return site.code || place || "";
|
||||
}
|
||||
|
||||
/** Table cell label: `BK5 . Dallas, TX` (period separator). */
|
||||
export function formatSiteTableLabel(params: {
|
||||
code?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
fallbackLocation?: string;
|
||||
}): string {
|
||||
const code = params.code?.trim() ?? "";
|
||||
const place =
|
||||
[params.city, params.state].filter(Boolean).join(", ") ||
|
||||
(params.fallbackLocation?.trim() ?? "");
|
||||
if (code && place) {
|
||||
return `${code} . ${place}`;
|
||||
}
|
||||
return code || place || "";
|
||||
}
|
||||
|
||||
export function formatSitePlace(city?: string, state?: string, fallbackLocation = ""): string {
|
||||
return [city, state].filter(Boolean).join(", ") || fallbackLocation.trim() || "";
|
||||
}
|
||||
|
||||
/** Wizard preview: `3811 Distribution Dr, Dallas, TX 70068` */
|
||||
export function formatLocationAddressPreview(
|
||||
location: Pick<Location, "address" | "city" | "state" | "zipCode">,
|
||||
): string {
|
||||
const street = location.address?.trim() ?? "";
|
||||
const city = location.city?.trim() ?? "";
|
||||
const stateZip = [location.state?.trim(), location.zipCode?.trim()].filter(Boolean).join(" ");
|
||||
const cityStateZip = [city, stateZip].filter(Boolean).join(", ");
|
||||
|
||||
if (street && cityStateZip) {
|
||||
return `${street}, ${cityStateZip}`;
|
||||
}
|
||||
return street || cityStateZip || "";
|
||||
}
|
||||
|
||||
export function mapLocationSite(raw: unknown): LocationSite {
|
||||
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
code: readString(item, "code", "Code", "siteCode", "SiteCode"),
|
||||
city: readString(item, "city", "City"),
|
||||
state: readString(item, "state", "State"),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapLocationSites(rawItems: unknown[]): LocationSite[] {
|
||||
return rawItems.map(mapLocationSite);
|
||||
}
|
||||
|
||||
export function mapLocationListItem(raw: unknown): LocationListItem {
|
||||
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||
|
||||
|
|
@ -54,10 +115,17 @@ export function mapLocationListItems(rawItems: unknown[]): LocationListItem[] {
|
|||
export function mapLocation(raw: unknown): Location {
|
||||
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||
|
||||
const address =
|
||||
readString(item, "address", "Address") ||
|
||||
[readString(item, "address1", "Address1"), readString(item, "address2", "Address2")]
|
||||
.filter(Boolean)
|
||||
.join(", ") ||
|
||||
undefined;
|
||||
|
||||
return {
|
||||
id: readId(item) || undefined,
|
||||
name: readString(item, "name", "Name") || undefined,
|
||||
address: readString(item, "address", "Address") || undefined,
|
||||
address: address || undefined,
|
||||
city: readString(item, "city", "City") || undefined,
|
||||
state: readString(item, "state", "State") || undefined,
|
||||
zipCode: readString(item, "zipCode", "ZipCode", "Zip") || undefined,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@ export interface Location {
|
|||
accountId?: string | number;
|
||||
}
|
||||
|
||||
/** Site option from GET locations/sites (wizard / board create). */
|
||||
export interface LocationSite {
|
||||
id: string | number;
|
||||
code: string;
|
||||
city: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface LocationsListParams {
|
||||
search?: string;
|
||||
page?: number;
|
||||
|
|
|
|||
25
src/domain/locations/use-cases/use-location-sites.ts
Normal file
25
src/domain/locations/use-cases/use-location-sites.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { locationsApi } from "@/domain/locations/api/locations-api";
|
||||
import { formatLocationSiteLabel } from "@/domain/locations/mappers/location-mapper";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order-common";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
/** Sites for WO board wizard (`GET locations/sites`), mapped to LocationOption. */
|
||||
export function useLocationSites(search = ""): UseQueryResult<LocationOption[], Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.locations.sites(search),
|
||||
queryFn: async () => {
|
||||
const sites = await locationsApi.getSites(search || undefined);
|
||||
return sites.map(
|
||||
(site): LocationOption => ({
|
||||
id: site.id,
|
||||
name: formatLocationSiteLabel(site),
|
||||
siteCode: site.code,
|
||||
city: site.city,
|
||||
state: site.state,
|
||||
}),
|
||||
);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,18 +1,11 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
|
||||
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
|
||||
import { api, apiDelete, apiGet, apiPost, apiPostForm, apiPut } from "@/api/api";
|
||||
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
||||
import {
|
||||
isWoTableMockEnabled,
|
||||
shouldUseMockWorkOrderDetail,
|
||||
} from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
|
||||
import {
|
||||
buildMockWorkOrderDetailFromTableRow,
|
||||
getMockWorkOrderLocations,
|
||||
getMockWorkOrderTableRow,
|
||||
getMockWorkOrderVendors,
|
||||
getMockWorkOrdersList,
|
||||
MOCK_WO_TABLE_USERS,
|
||||
} from "@/domain/work-orders/mocks/wo-table-mock-data";
|
||||
BoardPatchConflictError,
|
||||
BoardPatchValidationError,
|
||||
} from "@/domain/work-orders/errors/board-patch-errors";
|
||||
import {
|
||||
mapDispatchComment,
|
||||
mapDispatchDetail,
|
||||
|
|
@ -23,12 +16,22 @@ import {
|
|||
mapTaskListTemplates,
|
||||
mapUpdateDispatchPayload,
|
||||
mapVendorDropdownItems,
|
||||
mapWorkOrderComment,
|
||||
mapWorkOrderDetail,
|
||||
mapWorkOrderFormToCreatePayload,
|
||||
mapWorkOrderFormToUpdatePayload,
|
||||
mapWorkOrderFormToUpdateFormData,
|
||||
mapWorkOrderTableRow,
|
||||
mapWorkOrderTableRows,
|
||||
} from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import {
|
||||
buildCompletionDocFormData,
|
||||
buildMediaFormData,
|
||||
mapBoardDetailComment,
|
||||
mapBoardDetailMediaItem,
|
||||
mapCompletion,
|
||||
mapWorkOrderBoardDetail,
|
||||
type UploadableMediaCategory,
|
||||
} from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
|
||||
import type {
|
||||
DispatchDetail,
|
||||
|
|
@ -44,11 +47,56 @@ import type {
|
|||
WorkOrderFormValues,
|
||||
WorkOrderUser,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type {
|
||||
WorkOrderBoardListResult,
|
||||
WorkOrderBoardPatchPayload,
|
||||
WorkOrderBoardQueryParams,
|
||||
WorkOrderBoardResponseApi,
|
||||
WorkOrderBoardSearchQueryParams,
|
||||
} from "@/domain/work-orders/types/work-order-board";
|
||||
import type {
|
||||
WorkOrderBoardCompletion,
|
||||
WorkOrderBoardDetail,
|
||||
WorkOrderBoardMediaItem,
|
||||
} from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
DISPATCH_LOCATION_WO_PAGE_SIZE,
|
||||
type WorkOrdersListParams,
|
||||
type WorkOrdersListResult,
|
||||
} from "@/domain/work-orders/types/work-order-list";
|
||||
import { buildBoardSearchParamsRecord } from "@/domain/work-orders/utils/board-query-params";
|
||||
|
||||
async function readResponseJson(response: Response): Promise<unknown> {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function throwDocumentHttpError(response: Response, data: unknown): never {
|
||||
const record = typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
|
||||
|
||||
if (response.status === 409) {
|
||||
throw new BoardPatchConflictError(
|
||||
typeof record.message === "string"
|
||||
? record.message
|
||||
: "The record was modified by another user. Refresh and retry.",
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 422) {
|
||||
throw new BoardPatchValidationError(
|
||||
typeof record.code === "string" ? record.code : "ValidationError",
|
||||
typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data),
|
||||
);
|
||||
}
|
||||
|
||||
throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
|
||||
}
|
||||
|
||||
function buildSearchParams(params: WorkOrdersListParams): Record<string, string | number> {
|
||||
const searchParams: Record<string, string | number> = {
|
||||
|
|
@ -88,6 +136,22 @@ function buildSearchParams(params: WorkOrdersListParams): Record<string, string
|
|||
return searchParams;
|
||||
}
|
||||
|
||||
function toUrlSearchParams(
|
||||
params: Record<string, string | number | boolean | (string | number)[]>,
|
||||
): URLSearchParams {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
sp.append(key, String(entry));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
sp.append(key, String(value));
|
||||
}
|
||||
return sp;
|
||||
}
|
||||
|
||||
function mapUser(raw: unknown): WorkOrderUser {
|
||||
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||
const id = item.id ?? item.Id;
|
||||
|
|
@ -103,17 +167,8 @@ function mapUser(raw: unknown): WorkOrderUser {
|
|||
};
|
||||
}
|
||||
|
||||
async function mockNetworkDelay(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
export const workOrdersApi = {
|
||||
getList: async (params: WorkOrdersListParams = {}): Promise<WorkOrdersListResult> => {
|
||||
if (isWoTableMockEnabled()) {
|
||||
await mockNetworkDelay();
|
||||
return getMockWorkOrdersList(params);
|
||||
}
|
||||
|
||||
const searchParams = buildSearchParams(params);
|
||||
const data = await apiGet<unknown>(API_PATHS.workOrder.getList, { searchParams });
|
||||
const paginated = handlePaginatedResponse<unknown>(data, {
|
||||
|
|
@ -129,6 +184,53 @@ export const workOrdersApi = {
|
|||
};
|
||||
},
|
||||
|
||||
getBoard: async (params: WorkOrderBoardQueryParams): Promise<WorkOrderBoardListResult> => {
|
||||
const searchParams = toUrlSearchParams(buildBoardSearchParamsRecord(params));
|
||||
const data = await apiGet<unknown>(API_PATHS.workOrder.board, { searchParams });
|
||||
const payload = (
|
||||
typeof data === "object" && data !== null ? data : {}
|
||||
) as WorkOrderBoardResponseApi;
|
||||
|
||||
const scheduled = mapWorkOrderTableRows(payload.scheduled ?? []);
|
||||
const unscheduled = mapWorkOrderTableRows(payload.unscheduled ?? []);
|
||||
const returnedCount = payload.counts?.returned ?? scheduled.length;
|
||||
const totalCount = payload.counts?.total ?? returnedCount;
|
||||
|
||||
return {
|
||||
items: [...unscheduled, ...scheduled],
|
||||
scheduled,
|
||||
unscheduled,
|
||||
totalCount,
|
||||
returnedCount,
|
||||
page: 1,
|
||||
pageSize: scheduled.length + unscheduled.length,
|
||||
weekStart: payload.weekStart,
|
||||
weekEnd: payload.weekEnd,
|
||||
};
|
||||
},
|
||||
|
||||
searchBoard: async (
|
||||
params: WorkOrderBoardSearchQueryParams,
|
||||
): Promise<WorkOrderBoardListResult> => {
|
||||
const searchParams = toUrlSearchParams(buildBoardSearchParamsRecord(params));
|
||||
const data = await apiGet<unknown>(API_PATHS.workOrder.boardSearch, { searchParams });
|
||||
const paginated = handlePaginatedResponse<unknown>(data, {
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
});
|
||||
const items = mapWorkOrderTableRows(paginated.items);
|
||||
|
||||
return {
|
||||
items,
|
||||
scheduled: items.filter((row) => !!row.scheduledOn),
|
||||
unscheduled: items.filter((row) => !row.scheduledOn),
|
||||
totalCount: paginated.totalCount,
|
||||
returnedCount: items.length,
|
||||
page: paginated.page,
|
||||
pageSize: paginated.pageSize,
|
||||
};
|
||||
},
|
||||
|
||||
getListByLocation: async (
|
||||
locationId: string | number,
|
||||
search = "",
|
||||
|
|
@ -144,29 +246,38 @@ export const workOrdersApi = {
|
|||
},
|
||||
|
||||
fetchUsers: async (): Promise<WorkOrderUser[]> => {
|
||||
if (isWoTableMockEnabled()) {
|
||||
return MOCK_WO_TABLE_USERS;
|
||||
}
|
||||
const data = await apiGet<unknown>(API_PATHS.user.list);
|
||||
const users = handleApiResponse<unknown[]>(data);
|
||||
return Array.isArray(users) ? users.map(mapUser) : [];
|
||||
},
|
||||
|
||||
getById: async (id: string | number): Promise<WorkOrderDetail> => {
|
||||
if (shouldUseMockWorkOrderDetail(id)) {
|
||||
await mockNetworkDelay();
|
||||
const row = getMockWorkOrderTableRow(id);
|
||||
if (!row) {
|
||||
throw new Error("Mock work order not found");
|
||||
fetchDispatchers: async (): Promise<WorkOrderUser[]> => {
|
||||
try {
|
||||
const data = await apiGet<unknown>(API_PATHS.workOrder.lookupsDispatchers);
|
||||
const users = handleApiResponse<unknown[]>(data);
|
||||
const mapped = Array.isArray(users) ? users.map(mapUser) : [];
|
||||
if (mapped.length > 0) {
|
||||
return mapped;
|
||||
}
|
||||
return buildMockWorkOrderDetailFromTableRow(row);
|
||||
} catch {
|
||||
// Fall back to User list when lookup is unavailable.
|
||||
}
|
||||
|
||||
return workOrdersApi.fetchUsers();
|
||||
},
|
||||
|
||||
getById: async (id: string | number): Promise<WorkOrderDetail> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.workOrder.getById, {
|
||||
searchParams: { id },
|
||||
});
|
||||
return mapWorkOrderDetail(handleApiResponse(data));
|
||||
},
|
||||
|
||||
getBoardDetail: async (id: string | number): Promise<WorkOrderBoardDetail> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.workOrder.boardDetail(id));
|
||||
return mapWorkOrderBoardDetail(handleApiResponse(data));
|
||||
},
|
||||
|
||||
create: async (
|
||||
values: WorkOrderFormSchemaValues | WorkOrderFormValues,
|
||||
): Promise<WorkOrderDetail> => {
|
||||
|
|
@ -175,29 +286,65 @@ export const workOrdersApi = {
|
|||
return mapWorkOrderDetail(handleApiResponse(data));
|
||||
},
|
||||
|
||||
createFromBoard: async (draft: WorkOrderWizardDraft): Promise<WorkOrderDetail> => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload(draft);
|
||||
const data = await apiPost<unknown>(API_PATHS.workOrder.boardCreate, payload);
|
||||
return mapWorkOrderDetail(handleApiResponse(data));
|
||||
},
|
||||
|
||||
patchBoardField: async (
|
||||
id: string | number,
|
||||
payload: WorkOrderBoardPatchPayload,
|
||||
): Promise<WorkOrderTableRow> => {
|
||||
const response = await api.patch(API_PATHS.workOrder.boardPatch(id), {
|
||||
json: payload,
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
data = undefined;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return mapWorkOrderTableRow(data);
|
||||
}
|
||||
|
||||
const record =
|
||||
typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
|
||||
|
||||
if (response.status === 409) {
|
||||
const currentStateRaw = record.currentState ?? record.CurrentState;
|
||||
const currentState = currentStateRaw != null ? mapWorkOrderTableRow(currentStateRaw) : null;
|
||||
throw new BoardPatchConflictError(
|
||||
typeof record.message === "string"
|
||||
? record.message
|
||||
: "The record was modified by another user. Refresh and retry.",
|
||||
currentState,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 422) {
|
||||
throw new BoardPatchValidationError(
|
||||
typeof record.code === "string" ? record.code : "ValidationError",
|
||||
typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data),
|
||||
);
|
||||
}
|
||||
|
||||
throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
|
||||
},
|
||||
|
||||
update: async (
|
||||
id: string | number,
|
||||
values: WorkOrderFormSchemaValues | WorkOrderFormValues,
|
||||
): Promise<WorkOrderDetail> => {
|
||||
if (shouldUseMockWorkOrderDetail(id)) {
|
||||
await mockNetworkDelay();
|
||||
const row = getMockWorkOrderTableRow(id);
|
||||
if (!row) {
|
||||
throw new Error("Mock work order not found");
|
||||
}
|
||||
return buildMockWorkOrderDetailFromTableRow({
|
||||
...row,
|
||||
woNumber: values.workOrderNumber || row.woNumber,
|
||||
dueDate: values.dueDate || row.dueDate,
|
||||
scheduledOn: values.scheduledDate || row.scheduledOn,
|
||||
dispatcherName: values.assignedTo || row.dispatcherName,
|
||||
pm: values.problem || row.pm,
|
||||
type: (values.source || row.type) as typeof row.type,
|
||||
});
|
||||
}
|
||||
const payload = mapWorkOrderFormToUpdatePayload(id, values);
|
||||
const data = await apiPost<unknown>(API_PATHS.workOrder.editWorkorder, payload);
|
||||
return mapWorkOrderDetail(handleApiResponse(data));
|
||||
): Promise<void> => {
|
||||
const formData = mapWorkOrderFormToUpdateFormData(id, values);
|
||||
await apiPostForm<{ message?: string; status?: string }>(
|
||||
API_PATHS.workOrder.editWorkorder,
|
||||
formData,
|
||||
);
|
||||
},
|
||||
|
||||
delete: async (id: string | number): Promise<void> => {
|
||||
|
|
@ -206,43 +353,135 @@ export const workOrdersApi = {
|
|||
});
|
||||
},
|
||||
|
||||
listComments: async (workOrderId: string | number): Promise<WorkOrderComment[]> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.workOrder.comments(workOrderId));
|
||||
const items = handleApiResponse<unknown[]>(data);
|
||||
return Array.isArray(items) ? items.map(mapBoardDetailComment) : [];
|
||||
},
|
||||
|
||||
createComment: async (workOrderId: string | number, text: string): Promise<WorkOrderComment> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.workOrder.comments(workOrderId), { text });
|
||||
return mapBoardDetailComment(handleApiResponse(data));
|
||||
},
|
||||
|
||||
updateComment: async (
|
||||
workOrderId: string | number,
|
||||
commentId: string | number,
|
||||
text: string,
|
||||
): Promise<WorkOrderComment> => {
|
||||
const response = await api.patch(API_PATHS.workOrder.commentItem(workOrderId, commentId), {
|
||||
json: { text },
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const data = await readResponseJson(response);
|
||||
|
||||
if (response.ok) {
|
||||
if (response.status === 204 || data == null) {
|
||||
return mapBoardDetailComment({ id: commentId, text });
|
||||
}
|
||||
return mapBoardDetailComment(handleApiResponse(data) ?? data);
|
||||
}
|
||||
|
||||
throwDocumentHttpError(response, data);
|
||||
},
|
||||
|
||||
/** @deprecated Prefer createComment — modern board endpoint. */
|
||||
addComment: async (
|
||||
workOrderId: string | number,
|
||||
text: string,
|
||||
commentType: string,
|
||||
_commentType?: string,
|
||||
): Promise<WorkOrderComment> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.workOrder.addCommentJson, {
|
||||
WorkorderId: workOrderId,
|
||||
Text: text,
|
||||
CommentType: commentType,
|
||||
return workOrdersApi.createComment(workOrderId, text);
|
||||
},
|
||||
|
||||
listMedia: async (workOrderId: string | number): Promise<WorkOrderBoardMediaItem[]> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.workOrder.media(workOrderId));
|
||||
const items = handleApiResponse<unknown[]>(data);
|
||||
return Array.isArray(items) ? items.map(mapBoardDetailMediaItem) : [];
|
||||
},
|
||||
|
||||
uploadMedia: async (
|
||||
workOrderId: string | number,
|
||||
file: File,
|
||||
category: UploadableMediaCategory,
|
||||
): Promise<WorkOrderBoardMediaItem> => {
|
||||
const formData = buildMediaFormData(file, category);
|
||||
const response = await api.post(API_PATHS.workOrder.media(workOrderId), {
|
||||
body: formData,
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
return mapWorkOrderComment(handleApiResponse(data));
|
||||
const data = await readResponseJson(response);
|
||||
if (response.ok) {
|
||||
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
},
|
||||
|
||||
deleteMedia: async (workOrderId: string | number, mediaId: string | number): Promise<void> => {
|
||||
const numericId = typeof mediaId === "number" ? mediaId : Number(mediaId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
throw new BoardPatchValidationError(
|
||||
"InvalidMedia",
|
||||
"Legacy media cannot be deleted via this endpoint.",
|
||||
);
|
||||
}
|
||||
|
||||
const response = await api.delete(API_PATHS.workOrder.mediaItem(workOrderId, numericId), {
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await readResponseJson(response);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
},
|
||||
|
||||
uploadCompletionDoc: async (
|
||||
workOrderId: string | number,
|
||||
params: {
|
||||
file: File;
|
||||
workOrderVersion: string;
|
||||
signOffName?: string;
|
||||
signOffSignature?: string;
|
||||
},
|
||||
): Promise<WorkOrderBoardCompletion> => {
|
||||
if (!params.workOrderVersion) {
|
||||
throw new BoardPatchValidationError(
|
||||
"WorkOrderVersionRequired",
|
||||
"workOrderVersion is required to upload a completion document.",
|
||||
);
|
||||
}
|
||||
|
||||
const formData = buildCompletionDocFormData(params);
|
||||
const response = await api.post(API_PATHS.workOrder.completionDoc(workOrderId), {
|
||||
body: formData,
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const data = await readResponseJson(response);
|
||||
if (response.ok) {
|
||||
return mapCompletion(handleApiResponse(data) ?? data);
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
},
|
||||
|
||||
changeStatus: async (id: string | number, status: string): Promise<void> => {
|
||||
if (shouldUseMockWorkOrderDetail(id)) {
|
||||
await mockNetworkDelay();
|
||||
return;
|
||||
}
|
||||
await apiPost<unknown>(API_PATHS.workOrder.changeStatus, undefined, {
|
||||
searchParams: { id, status },
|
||||
});
|
||||
},
|
||||
|
||||
changeAssignment: async (id: string | number, assignTo: string | number): Promise<void> => {
|
||||
if (shouldUseMockWorkOrderDetail(id)) {
|
||||
await mockNetworkDelay();
|
||||
return;
|
||||
}
|
||||
await apiPost<unknown>(API_PATHS.workOrder.changeAssignment, undefined, {
|
||||
searchParams: { id, assignTo: assignTo || "" },
|
||||
});
|
||||
},
|
||||
|
||||
fetchLocations: async (): Promise<LocationOption[]> => {
|
||||
if (isWoTableMockEnabled()) {
|
||||
return getMockWorkOrderLocations();
|
||||
}
|
||||
const data = await apiGet<unknown>(API_PATHS.contact.getLocations);
|
||||
const items = handleApiResponse<unknown[]>(data);
|
||||
return Array.isArray(items) ? mapLocationOptions(items) : [];
|
||||
|
|
@ -255,9 +494,6 @@ export const workOrdersApi = {
|
|||
},
|
||||
|
||||
fetchVendorsDropdown: async (trade?: string, siteZip?: string): Promise<VendorDropdownItem[]> => {
|
||||
if (isWoTableMockEnabled()) {
|
||||
return getMockWorkOrderVendors();
|
||||
}
|
||||
const searchParams: Record<string, string> = {};
|
||||
if (trade) {
|
||||
searchParams.trade = trade;
|
||||
|
|
|
|||
23
src/domain/work-orders/errors/board-patch-errors.ts
Normal file
23
src/domain/work-orders/errors/board-patch-errors.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
export class BoardPatchConflictError extends Error {
|
||||
readonly status = 409;
|
||||
readonly currentState: WorkOrderTableRow | null;
|
||||
|
||||
constructor(message: string, currentState: WorkOrderTableRow | null) {
|
||||
super(message);
|
||||
this.name = "BoardPatchConflictError";
|
||||
this.currentState = currentState;
|
||||
}
|
||||
}
|
||||
|
||||
export class BoardPatchValidationError extends Error {
|
||||
readonly status = 422;
|
||||
readonly code: string;
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "BoardPatchValidationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
13
src/domain/work-orders/flag-colors.ts
Normal file
13
src/domain/work-orders/flag-colors.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/** Fixed board flag palette — must match BE `WorkOrderFlagColors`. */
|
||||
export const FLAG_COLORS: { name: string; value: string }[] = [
|
||||
{ name: "Red", value: "#EF4444" },
|
||||
{ name: "Orange", value: "#F97316" },
|
||||
{ name: "Amber", value: "#F59E0B" },
|
||||
{ name: "Yellow", value: "#EAB308" },
|
||||
{ name: "Green", value: "#22C55E" },
|
||||
{ name: "Teal", value: "#14B8A6" },
|
||||
{ name: "Blue", value: "#3B82F6" },
|
||||
{ name: "Purple", value: "#8B5CF6" },
|
||||
];
|
||||
|
||||
export const FLAG_COLOR_VALUES = FLAG_COLORS.map((c) => c.value);
|
||||
257
src/domain/work-orders/mappers/work-order-board-detail-mapper.ts
Normal file
257
src/domain/work-orders/mappers/work-order-board-detail-mapper.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { DOC_STATUS_API } from "@/domain/work-orders/types/work-order-board";
|
||||
import type {
|
||||
BoardMediaCategory,
|
||||
WorkOrderBoardCompletion,
|
||||
WorkOrderBoardDetail,
|
||||
WorkOrderBoardMediaItem,
|
||||
} from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import { BOARD_MEDIA_CATEGORY_API } from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type {
|
||||
WorkOrderAuditEntry,
|
||||
WorkOrderComment,
|
||||
} from "@/domain/work-orders/types/work-order-detail";
|
||||
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import type {
|
||||
FileCategory,
|
||||
MediaCategory,
|
||||
MediaFile,
|
||||
} from "@/domain/work-orders/types/work-order-media";
|
||||
|
||||
function asRecord(raw: unknown): Record<string, unknown> {
|
||||
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function readNumber(record: Record<string, unknown>, ...keys: string[]): number | null {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readBoolean(record: Record<string, unknown>, ...keys: string[]): boolean {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function mapDocStatus(raw: unknown): CompletionDocStatus {
|
||||
const numeric = typeof raw === "number" ? raw : Number(raw);
|
||||
if (numeric === DOC_STATUS_API.Yes) return "Yes";
|
||||
if (numeric === DOC_STATUS_API.No) return "No";
|
||||
if (numeric === DOC_STATUS_API.NotNeeded) return "NN";
|
||||
if (raw === "Yes" || raw === "No" || raw === "NN") return raw;
|
||||
if (raw === "NotNeeded") return "NN";
|
||||
return "";
|
||||
}
|
||||
|
||||
function mapMediaCategory(raw: unknown): BoardMediaCategory {
|
||||
const numeric = typeof raw === "number" ? raw : Number(raw);
|
||||
if (numeric === BOARD_MEDIA_CATEGORY_API.Before) return "Before";
|
||||
if (numeric === BOARD_MEDIA_CATEGORY_API.After) return "After";
|
||||
if (numeric === BOARD_MEDIA_CATEGORY_API.Extra) return "Extra";
|
||||
if (numeric === BOARD_MEDIA_CATEGORY_API.Completion) return "Completion";
|
||||
return "Extra";
|
||||
}
|
||||
|
||||
export function mapBoardDetailComment(raw: unknown): WorkOrderComment {
|
||||
const item = asRecord(raw);
|
||||
const id = readNumber(item, "id");
|
||||
const authorId = readString(item, "authorId", "AuthorId") || null;
|
||||
return {
|
||||
id: id ?? (readString(item, "id") || `comment-${readString(item, "time")}`),
|
||||
createdDate: readString(item, "time", "createdDate", "CreatedDate"),
|
||||
commenttext: readString(item, "text", "commenttext", "Commenttext"),
|
||||
commentType: readString(item, "commentType", "CommentType") || "internal",
|
||||
userName: readString(item, "userName", "UserName", "authorName", "AuthorName"),
|
||||
authorId,
|
||||
commenter: readString(item, "commenter", "Commenter") || null,
|
||||
recordType: readString(item, "recordType", "RecordType") || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBoardDetailAuditEntry(raw: unknown, index: number): WorkOrderAuditEntry {
|
||||
const item = asRecord(raw);
|
||||
const time = readString(item, "time", "createdAt", "CreatedAt");
|
||||
const typeRaw = readString(item, "type", "Type").toLowerCase();
|
||||
const type: "manual" | "system" = typeRaw === "system" ? "system" : "manual";
|
||||
const dispatcherId = readString(item, "dispatcherId", "DispatcherId");
|
||||
const userName = readString(item, "userName", "UserName");
|
||||
|
||||
return {
|
||||
id: `${time || "audit"}-${index}`,
|
||||
fieldName: readString(item, "fieldName", "FieldName"),
|
||||
oldValue: readString(item, "oldValue", "OldValue"),
|
||||
newValue: readString(item, "newValue", "NewValue"),
|
||||
action: readString(item, "action", "Action"),
|
||||
createdAt: time,
|
||||
userName: type === "system" ? "System" : userName,
|
||||
type,
|
||||
dispatcherId: dispatcherId || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export type UploadableMediaCategory = "Before" | "After" | "Extra";
|
||||
|
||||
export function buildMediaFormData(file: File, category: UploadableMediaCategory): FormData {
|
||||
const form = new FormData();
|
||||
form.append("category", category);
|
||||
form.append("file", file);
|
||||
return form;
|
||||
}
|
||||
|
||||
export function buildCompletionDocFormData(params: {
|
||||
file: File;
|
||||
workOrderVersion: string;
|
||||
signOffName?: string;
|
||||
signOffSignature?: string;
|
||||
}): FormData {
|
||||
const form = new FormData();
|
||||
form.append("file", params.file);
|
||||
form.append("workOrderVersion", params.workOrderVersion);
|
||||
if (params.signOffName) {
|
||||
form.append("signOffName", params.signOffName);
|
||||
}
|
||||
if (params.signOffSignature) {
|
||||
form.append("signOffSignature", params.signOffSignature);
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
export function mapCompletion(raw: unknown): WorkOrderBoardCompletion {
|
||||
const item = asRecord(raw);
|
||||
const templateRaw = item.template ?? item.Template;
|
||||
const templateRecord = templateRaw != null ? asRecord(templateRaw) : null;
|
||||
const signoffsRaw = item.dispatchSignoffs ?? item.DispatchSignoffs;
|
||||
const signoffs = Array.isArray(signoffsRaw)
|
||||
? signoffsRaw.map((entry) => {
|
||||
const s = asRecord(entry);
|
||||
return {
|
||||
dispatchId: readNumber(s, "dispatchId", "DispatchId"),
|
||||
signoffType: readString(s, "signoffType", "SignoffType"),
|
||||
name: readString(s, "name", "Name"),
|
||||
signedAt: readString(s, "signedAt", "SignedAt"),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
docStatus: mapDocStatus(item.docStatus ?? item.DocStatus),
|
||||
template: templateRecord
|
||||
? {
|
||||
id: readNumber(templateRecord, "id", "Id") ?? 0,
|
||||
name: readString(templateRecord, "name", "Name"),
|
||||
serviceKey: readString(templateRecord, "serviceKey", "ServiceKey"),
|
||||
workOrderType: readNumber(templateRecord, "workOrderType", "WorkOrderType"),
|
||||
templateUrl: readString(templateRecord, "templateUrl", "TemplateUrl"),
|
||||
isActive: readBoolean(templateRecord, "isActive", "IsActive"),
|
||||
}
|
||||
: null,
|
||||
signOffName: readString(item, "signOffName", "SignOffName"),
|
||||
signOffAttachment: readString(item, "signOffAttachment", "SignOffAttachment"),
|
||||
signOffSignature: readString(item, "signOffSignature", "SignOffSignature"),
|
||||
dispatchSignoffs: signoffs,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBoardDetailMediaItem(raw: unknown): WorkOrderBoardMediaItem {
|
||||
const item = asRecord(raw);
|
||||
const id = readNumber(item, "id");
|
||||
const isLegacyFlag = readBoolean(item, "isLegacy", "IsLegacy");
|
||||
return {
|
||||
id: String(id ?? (readString(item, "id") || cryptoRandomId())),
|
||||
category: mapMediaCategory(item.category ?? item.Category),
|
||||
url: readString(item, "url", "Url"),
|
||||
fileName: readString(item, "fileName", "FileName") || "file",
|
||||
uploadedAt: readString(item, "uploadedAt", "UploadedAt"),
|
||||
isLegacy: isLegacyFlag || (id != null && id <= 0),
|
||||
};
|
||||
}
|
||||
|
||||
function cryptoRandomId(): string {
|
||||
return `media-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
function classifyFileName(fileName: string, url: string): FileCategory {
|
||||
const name = `${fileName} ${url}`.toLowerCase();
|
||||
if (/\.(jpe?g|png|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) {
|
||||
return "image";
|
||||
}
|
||||
if (/\.(mp4|mov|webm|m4v)(\?|$)/i.test(name) || name.includes("video/")) {
|
||||
return "video";
|
||||
}
|
||||
if (/\.pdf(\?|$)/i.test(name)) {
|
||||
return "pdf";
|
||||
}
|
||||
if (/\.(docx?|xlsx?|pptx?)(\?|$)/i.test(name)) {
|
||||
return "doc";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
function boardCategoryToMediaCategory(category: BoardMediaCategory): MediaCategory {
|
||||
if (category === "Extra") return "Other";
|
||||
if (category === "Completion") return "Report";
|
||||
return "Photo";
|
||||
}
|
||||
|
||||
/** Adapt board detail media items for existing MediaUploader / Extras UI. */
|
||||
export function boardMediaItemsToMediaFiles(items: WorkOrderBoardMediaItem[]): MediaFile[] {
|
||||
return items.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.fileName,
|
||||
fileType: classifyFileName(item.fileName, item.url),
|
||||
url: item.url,
|
||||
category: boardCategoryToMediaCategory(item.category),
|
||||
isLegacy: item.isLegacy || Number(item.id) <= 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export function mapWorkOrderBoardDetail(raw: unknown): WorkOrderBoardDetail {
|
||||
const root = asRecord(raw);
|
||||
const infoRaw = root.info ?? root.Info ?? root;
|
||||
const commentsRaw = root.comments ?? root.Comments;
|
||||
const auditRaw = root.audit ?? root.Audit;
|
||||
const mediaRaw = root.media ?? root.Media;
|
||||
const completionRaw = root.completion ?? root.Completion;
|
||||
|
||||
const comments = Array.isArray(commentsRaw) ? commentsRaw.map(mapBoardDetailComment) : [];
|
||||
const audit = Array.isArray(auditRaw)
|
||||
? auditRaw.map((entry, index) => mapBoardDetailAuditEntry(entry, index))
|
||||
: [];
|
||||
const media = Array.isArray(mediaRaw) ? mediaRaw.map(mapBoardDetailMediaItem) : [];
|
||||
|
||||
return {
|
||||
info: mapWorkOrderTableRow(infoRaw),
|
||||
completion: mapCompletion(completionRaw ?? {}),
|
||||
comments,
|
||||
audit,
|
||||
media,
|
||||
};
|
||||
}
|
||||
249
src/domain/work-orders/mappers/work-order-board-mapper.ts
Normal file
249
src/domain/work-orders/mappers/work-order-board-mapper.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import type {
|
||||
WorkOrderBoardCreatePayload,
|
||||
WorkOrderTypeApi,
|
||||
} from "@/domain/work-orders/types/work-order-board";
|
||||
|
||||
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
|
||||
|
||||
import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
import {
|
||||
fmtDateRange,
|
||||
formatApptTimeValue,
|
||||
mondayOf,
|
||||
} from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
const WO_TYPE_TO_API: Record<WOType, WorkOrderTypeApi> = {
|
||||
PM: WORK_ORDER_TYPE_API.PM,
|
||||
|
||||
Emergency: WORK_ORDER_TYPE_API.Emergency,
|
||||
|
||||
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
||||
|
||||
"Add-On": WORK_ORDER_TYPE_API.AddOn,
|
||||
|
||||
Overdue: WORK_ORDER_TYPE_API.Other,
|
||||
};
|
||||
|
||||
/** Leading token of a location label, e.g. `BK5` from `BK5 · Dallas` or `CHI-1042` from `CHI-1042 — Chicago`. */
|
||||
|
||||
export function parseSiteCodeFromLocationName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^([A-Za-z0-9-]+)/);
|
||||
|
||||
return match?.[1] ?? "";
|
||||
}
|
||||
|
||||
export function resolveSiteCode(option: {
|
||||
siteCode?: string;
|
||||
|
||||
name?: string;
|
||||
}): string {
|
||||
if (option.siteCode?.trim()) {
|
||||
return option.siteCode.trim();
|
||||
}
|
||||
|
||||
return parseSiteCodeFromLocationName(option.name ?? "");
|
||||
}
|
||||
|
||||
export function mapWizardTypeToApi(type: WOType | ""): WorkOrderTypeApi | null {
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return WO_TYPE_TO_API[type];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
* Legacy/general description only.
|
||||
|
||||
* Service notes, extras, POC, and vendor notes are sent as structured fields.
|
||||
|
||||
*/
|
||||
|
||||
export function buildBoardDescription(draft: WorkOrderWizardDraft): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (draft.schedMode === "range" && draft.scheduledOn && draft.scheduleEnd) {
|
||||
parts.push(`Schedule range: ${fmtDateRange(draft.scheduledOn, draft.scheduleEnd)}`);
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function trimOrUndefined(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeExtraServices(
|
||||
primary: string | undefined,
|
||||
extras: string[],
|
||||
): string[] | undefined {
|
||||
if (extras.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
|
||||
const primaryKey = primary?.toLowerCase() ?? "";
|
||||
|
||||
const out: string[] = [];
|
||||
|
||||
for (const raw of extras) {
|
||||
const trimmed = raw.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = trimmed.toLowerCase();
|
||||
|
||||
if (key === primaryKey || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
|
||||
out.push(trimmed);
|
||||
}
|
||||
|
||||
return out.length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
export function mapWizardDraftToBoardCreatePayload(
|
||||
draft: WorkOrderWizardDraft,
|
||||
): WorkOrderBoardCreatePayload {
|
||||
const workOrderType = mapWizardTypeToApi(draft.type);
|
||||
|
||||
const siteCode = draft.siteCode.trim() || resolveSiteCode({ name: draft.locationName });
|
||||
|
||||
if (workOrderType === null) {
|
||||
throw new Error("Work order type is required");
|
||||
}
|
||||
|
||||
if (!siteCode) {
|
||||
throw new Error("Site code is required");
|
||||
}
|
||||
|
||||
const payload: WorkOrderBoardCreatePayload = {
|
||||
workOrderType,
|
||||
|
||||
siteCode,
|
||||
};
|
||||
|
||||
if (draft.woNumber.trim()) {
|
||||
payload.woNumber = draft.woNumber.trim();
|
||||
}
|
||||
|
||||
if (draft.assigneeId.trim()) {
|
||||
payload.assignTo = draft.assigneeId.trim();
|
||||
}
|
||||
|
||||
if (draft.dueDate) {
|
||||
payload.dueDate = draft.dueDate;
|
||||
}
|
||||
|
||||
const isRange = draft.schedMode === "range";
|
||||
|
||||
if (!isRange && draft.scheduleWeekOnly) {
|
||||
payload.scheduleWeekOnly = true;
|
||||
|
||||
const week = draft.targetWeek?.trim() || (draft.scheduledOn ? mondayOf(draft.scheduledOn) : "");
|
||||
|
||||
if (week) {
|
||||
payload.targetWeek = week;
|
||||
}
|
||||
} else if (!isRange && draft.scheduledOn) {
|
||||
payload.scheduledDate = draft.scheduledOn;
|
||||
}
|
||||
|
||||
const vendorId = draft.vendorId.trim() ? Number(draft.vendorId) : NaN;
|
||||
|
||||
const hasVendor = Number.isFinite(vendorId);
|
||||
|
||||
if (hasVendor) {
|
||||
payload.vendorId = vendorId;
|
||||
}
|
||||
|
||||
const apptTime = formatApptTimeValue(draft.apptStart, draft.apptEnd);
|
||||
|
||||
if (hasVendor && (draft.scheduledOn || apptTime) && !draft.scheduleWeekOnly && !isRange) {
|
||||
if (draft.scheduledOn) {
|
||||
payload.apptDate = draft.scheduledOn;
|
||||
}
|
||||
|
||||
if (apptTime) {
|
||||
payload.apptTime = apptTime;
|
||||
}
|
||||
}
|
||||
|
||||
const primaryService = trimOrUndefined(draft.pm);
|
||||
|
||||
if (primaryService) {
|
||||
payload.primaryService = primaryService;
|
||||
}
|
||||
|
||||
const extraServices = normalizeExtraServices(primaryService, draft.extraServices);
|
||||
|
||||
if (extraServices) {
|
||||
payload.extraServices = extraServices;
|
||||
}
|
||||
|
||||
const serviceNotes = trimOrUndefined(draft.pmNote);
|
||||
|
||||
if (serviceNotes) {
|
||||
payload.serviceNotes = serviceNotes;
|
||||
}
|
||||
|
||||
const pocName = trimOrUndefined(draft.pocName);
|
||||
|
||||
if (pocName) {
|
||||
payload.pocName = pocName;
|
||||
}
|
||||
|
||||
const pocPhone = trimOrUndefined(draft.pocPhone);
|
||||
|
||||
if (pocPhone) {
|
||||
payload.pocPhone = pocPhone;
|
||||
}
|
||||
|
||||
const pocNotes = trimOrUndefined(draft.pocNotes);
|
||||
|
||||
if (pocNotes) {
|
||||
payload.pocNotes = pocNotes;
|
||||
}
|
||||
|
||||
const techPhone = trimOrUndefined(draft.techPhone);
|
||||
|
||||
if (techPhone) {
|
||||
payload.techPhone = techPhone;
|
||||
}
|
||||
|
||||
const vendorNotes = trimOrUndefined(draft.vendorNotes);
|
||||
|
||||
if (vendorNotes) {
|
||||
payload.vendorNotes = vendorNotes;
|
||||
}
|
||||
|
||||
const description = buildBoardDescription(draft);
|
||||
|
||||
if (description) {
|
||||
payload.description = description;
|
||||
}
|
||||
|
||||
const locationId = draft.locationId.trim() ? Number(draft.locationId) : NaN;
|
||||
|
||||
if (Number.isFinite(locationId)) {
|
||||
payload.locationId = locationId;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
162
src/domain/work-orders/mappers/work-order-board-patch-mapper.ts
Normal file
162
src/domain/work-orders/mappers/work-order-board-patch-mapper.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import type {
|
||||
WorkOrderBoardEditableField,
|
||||
WorkOrderBoardPatchPayload,
|
||||
} from "@/domain/work-orders/types/work-order-board";
|
||||
import type {
|
||||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
export type BoardFieldOp = {
|
||||
field: WorkOrderBoardEditableField;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const DISPATCH_FIELDS = new Set<WorkOrderBoardEditableField>(["vendorId", "apptDate", "apptTime"]);
|
||||
|
||||
const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
|
||||
"mediaCount",
|
||||
"dayGroup",
|
||||
"dayLabel",
|
||||
"dispatcherName",
|
||||
"company",
|
||||
"tech",
|
||||
"techPhone",
|
||||
"location",
|
||||
"locationId",
|
||||
"pocName",
|
||||
"pocPhone",
|
||||
"pocNotes",
|
||||
"title",
|
||||
"priority",
|
||||
"apptStart",
|
||||
"apptEnd",
|
||||
"rescheduleCount",
|
||||
"carriedOver",
|
||||
"isPastDue",
|
||||
"originalWeek",
|
||||
"originalDate",
|
||||
]);
|
||||
|
||||
export function mapDocStatusToApi(value: string): string {
|
||||
if (value === "NN") {
|
||||
return "NotNeeded";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {
|
||||
const keys = Object.keys(patch) as (keyof WorkOrderTablePatch)[];
|
||||
return keys.length > 0 && keys.every((key) => LOCAL_ONLY_KEYS.has(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands a UI table patch into ordered board field ops (one PATCH each).
|
||||
* Schedule/week-only fields are emitted first in a stable order.
|
||||
*/
|
||||
export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardFieldOp[] {
|
||||
if (isLocalOnlyTablePatch(patch)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ops: BoardFieldOp[] = [];
|
||||
|
||||
const hasScheduleRelated =
|
||||
patch.scheduledOn !== undefined ||
|
||||
patch.targetWeek !== undefined ||
|
||||
patch.scheduleWeekOnly !== undefined;
|
||||
|
||||
if (hasScheduleRelated) {
|
||||
if (patch.scheduleWeekOnly === true) {
|
||||
ops.push({ field: "scheduleWeekOnly", value: "true" });
|
||||
if (patch.targetWeek !== undefined) {
|
||||
ops.push({ field: "targetWeek", value: patch.targetWeek });
|
||||
}
|
||||
if (patch.scheduledOn !== undefined) {
|
||||
ops.push({ field: "scheduledDate", value: patch.scheduledOn });
|
||||
}
|
||||
} else {
|
||||
if (patch.scheduleWeekOnly === false) {
|
||||
ops.push({ field: "scheduleWeekOnly", value: "false" });
|
||||
}
|
||||
if (patch.scheduledOn !== undefined) {
|
||||
ops.push({ field: "scheduledDate", value: patch.scheduledOn });
|
||||
}
|
||||
if (patch.targetWeek !== undefined) {
|
||||
ops.push({ field: "targetWeek", value: patch.targetWeek });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.woNumber !== undefined) {
|
||||
ops.push({ field: "woNumber", value: patch.woNumber });
|
||||
}
|
||||
if (patch.type !== undefined) {
|
||||
ops.push({ field: "workOrderType", value: patch.type });
|
||||
}
|
||||
if (patch.site !== undefined) {
|
||||
ops.push({ field: "siteCode", value: patch.site });
|
||||
}
|
||||
if (patch.status !== undefined) {
|
||||
ops.push({ field: "lifecycleStatus", value: patch.status });
|
||||
}
|
||||
if (patch.dispatcherId !== undefined) {
|
||||
ops.push({ field: "assignTo", value: patch.dispatcherId });
|
||||
}
|
||||
if (patch.dueDate !== undefined) {
|
||||
ops.push({ field: "dueDate", value: patch.dueDate });
|
||||
}
|
||||
if (patch.vendorId !== undefined) {
|
||||
ops.push({ field: "vendorId", value: String(patch.vendorId) });
|
||||
}
|
||||
if (patch.apptTime !== undefined) {
|
||||
ops.push({ field: "apptTime", value: patch.apptTime });
|
||||
}
|
||||
if (patch.docStatus !== undefined) {
|
||||
ops.push({ field: "docStatus", value: mapDocStatusToApi(patch.docStatus) });
|
||||
}
|
||||
if (patch.pm !== undefined) {
|
||||
ops.push({ field: "pm", value: patch.pm });
|
||||
}
|
||||
if (patch.extraServices !== undefined) {
|
||||
ops.push({ field: "extraServices", value: JSON.stringify(patch.extraServices) });
|
||||
}
|
||||
if (patch.serviceNotes !== undefined) {
|
||||
ops.push({ field: "serviceNotes", value: patch.serviceNotes });
|
||||
}
|
||||
if (patch.flagColor !== undefined) {
|
||||
ops.push({ field: "flagColor", value: patch.flagColor ?? "" });
|
||||
}
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
export function isDispatchBoardField(field: WorkOrderBoardEditableField): boolean {
|
||||
return DISPATCH_FIELDS.has(field);
|
||||
}
|
||||
|
||||
export function buildBoardPatchPayload(
|
||||
op: BoardFieldOp,
|
||||
row: Pick<WorkOrderTableRow, "rowVersion" | "dispatchRowVersion" | "primaryDispatchId">,
|
||||
): WorkOrderBoardPatchPayload {
|
||||
if (!row.rowVersion) {
|
||||
throw new Error("workOrderVersion is required for board patch");
|
||||
}
|
||||
|
||||
const payload: WorkOrderBoardPatchPayload = {
|
||||
field: op.field,
|
||||
value: op.value,
|
||||
workOrderVersion: row.rowVersion,
|
||||
};
|
||||
|
||||
if (isDispatchBoardField(op.field)) {
|
||||
if (row.dispatchRowVersion) {
|
||||
payload.dispatchVersion = row.dispatchRowVersion;
|
||||
}
|
||||
if (row.primaryDispatchId != null) {
|
||||
payload.primaryDispatchId = row.primaryDispatchId;
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
|
@ -1,4 +1,10 @@
|
|||
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
||||
import { parseSiteCodeFromLocationName } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import {
|
||||
DOC_STATUS_API,
|
||||
LIFECYCLE_STATUS_API_TO_FE,
|
||||
WORK_ORDER_TYPE_API_TO_FE,
|
||||
} from "@/domain/work-orders/types/work-order-board";
|
||||
import type {
|
||||
DispatchChecklistItem,
|
||||
DispatchComment,
|
||||
|
|
@ -37,7 +43,15 @@ function toWorkOrderStatus(value: string): WorkOrderStatus {
|
|||
if (WORK_ORDER_STATUS_OPTIONS.includes(value as WorkOrderStatus)) {
|
||||
return value as WorkOrderStatus;
|
||||
}
|
||||
return "Open";
|
||||
const fromEditEnum: Record<string, WorkOrderStatus> = {
|
||||
Open: "Open",
|
||||
InProgress: "In Progress",
|
||||
OnHold: "On Hold",
|
||||
Completed: "Done",
|
||||
Cancelled: "Cancelled",
|
||||
Canceled: "Cancelled",
|
||||
};
|
||||
return fromEditEnum[value] ?? "Open";
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||
|
|
@ -77,6 +91,151 @@ function readNumber(record: Record<string, unknown>, ...keys: string[]): number
|
|||
return null;
|
||||
}
|
||||
|
||||
function readStringArray(record: Record<string, unknown>, ...keys: string[]): string[] {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function readIdList(raw: unknown): number[] | undefined {
|
||||
if (!Array.isArray(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ids: number[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === "number" && !Number.isNaN(entry)) {
|
||||
ids.push(entry);
|
||||
continue;
|
||||
}
|
||||
if (typeof entry === "string" && entry !== "") {
|
||||
const parsed = Number(entry);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
ids.push(parsed);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (typeof entry === "object" && entry !== null) {
|
||||
const id = readNumber(
|
||||
asRecord(entry),
|
||||
"id",
|
||||
"Id",
|
||||
"contactId",
|
||||
"ContactId",
|
||||
"categoryId",
|
||||
"CategoryId",
|
||||
);
|
||||
if (id != null) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
function readOptionalIdList(
|
||||
record: Record<string, unknown>,
|
||||
...keys: string[]
|
||||
): number[] | undefined {
|
||||
for (const key of keys) {
|
||||
if (!(key in record)) {
|
||||
continue;
|
||||
}
|
||||
const list = readIdList(record[key]);
|
||||
if (list !== undefined) {
|
||||
return list;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const FORM_STATUS_TO_EDIT_ENUM: Record<string, string> = {
|
||||
Open: "Open",
|
||||
"In Progress": "InProgress",
|
||||
InProgress: "InProgress",
|
||||
"On Hold": "OnHold",
|
||||
OnHold: "OnHold",
|
||||
Done: "Completed",
|
||||
Completed: "Completed",
|
||||
Cancelled: "Cancelled",
|
||||
Canceled: "Cancelled",
|
||||
};
|
||||
|
||||
const FORM_PRIORITY_TO_EDIT_ENUM: Record<string, string> = {
|
||||
"Sev 1": "Critical",
|
||||
"Sev 2": "High",
|
||||
"Sev 3": "Medium",
|
||||
"Sev 4": "Low",
|
||||
"Sev 5": "Low",
|
||||
Low: "Low",
|
||||
Medium: "Medium",
|
||||
High: "High",
|
||||
Critical: "Critical",
|
||||
};
|
||||
|
||||
export function mapFormStatusToEditEnum(status: string): string {
|
||||
return FORM_STATUS_TO_EDIT_ENUM[status] ?? status;
|
||||
}
|
||||
|
||||
export function mapFormPriorityToEditEnum(priority: string): string {
|
||||
return FORM_PRIORITY_TO_EDIT_ENUM[priority] ?? priority;
|
||||
}
|
||||
|
||||
function appendIfPresent(
|
||||
formData: FormData,
|
||||
key: string,
|
||||
value: string | number | null | undefined,
|
||||
): void {
|
||||
if (value === null || value === undefined) {
|
||||
return;
|
||||
}
|
||||
const asString = String(value).trim();
|
||||
if (asString === "") {
|
||||
return;
|
||||
}
|
||||
formData.append(key, asString);
|
||||
}
|
||||
|
||||
function appendIdList(formData: FormData, key: string, ids: number[] | undefined): void {
|
||||
if (ids === undefined) {
|
||||
return;
|
||||
}
|
||||
for (const id of ids) {
|
||||
formData.append(key, String(id));
|
||||
}
|
||||
}
|
||||
|
||||
function readRowVersion(record: Record<string, unknown>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
|
|
@ -111,26 +270,69 @@ export function mapWorkOrderListItems(rawItems: unknown[]): WorkOrderListItem[]
|
|||
const WO_TYPES_SET = new Set(["PM", "Reactive", "Emergency", "Add-On", "Overdue"]);
|
||||
|
||||
function readWoType(record: Record<string, unknown>): WOType | "" {
|
||||
const raw = readString(record, "type", "Type", "source", "Source", "woType", "WoType");
|
||||
const numeric =
|
||||
readNumber(record, "workOrderType", "WorkOrderType") ?? readNumber(record, "type", "Type");
|
||||
if (numeric != null && WORK_ORDER_TYPE_API_TO_FE[numeric]) {
|
||||
return WORK_ORDER_TYPE_API_TO_FE[numeric] as WOType;
|
||||
}
|
||||
|
||||
const raw = readString(
|
||||
record,
|
||||
"workOrderType",
|
||||
"WorkOrderType",
|
||||
"type",
|
||||
"Type",
|
||||
"woType",
|
||||
"WoType",
|
||||
"source",
|
||||
"Source",
|
||||
);
|
||||
if (WO_TYPES_SET.has(raw)) {
|
||||
return raw as WOType;
|
||||
}
|
||||
if (raw === "AddOn") {
|
||||
return "Add-On";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function readDocStatus(record: Record<string, unknown>): CompletionDocStatus {
|
||||
const numeric = readNumber(record, "docStatus", "DocStatus");
|
||||
if (numeric === DOC_STATUS_API.Yes) return "Yes";
|
||||
if (numeric === DOC_STATUS_API.No) return "No";
|
||||
if (numeric === DOC_STATUS_API.NotNeeded) return "NN";
|
||||
|
||||
const raw = readString(record, "docStatus", "DocStatus", "completionDoc", "CompletionDoc");
|
||||
if (raw === "Yes" || raw === "No" || raw === "NN") {
|
||||
return raw;
|
||||
}
|
||||
if (raw === "NotNeeded") {
|
||||
return "NN";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function readBoardStatus(record: Record<string, unknown>): ReturnType<typeof mapApiStatusToWizard> {
|
||||
const label = readString(record, "lifecycleStatusLabel", "LifecycleStatusLabel");
|
||||
if (label) {
|
||||
return mapApiStatusToWizard(label);
|
||||
}
|
||||
|
||||
const lifecycle = readNumber(record, "lifecycleStatus", "LifecycleStatus");
|
||||
if (lifecycle != null && LIFECYCLE_STATUS_API_TO_FE[lifecycle]) {
|
||||
return mapApiStatusToWizard(LIFECYCLE_STATUS_API_TO_FE[lifecycle]);
|
||||
}
|
||||
|
||||
const legacy = readString(record, "legacyStatus", "LegacyStatus", "status", "Status");
|
||||
return mapApiStatusToWizard(legacy);
|
||||
}
|
||||
|
||||
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||
const item = asRecord(raw);
|
||||
const scheduledOn = toDateInputValue(
|
||||
readString(item, "scheduledOn", "scheduledDate", "ScheduledDate", "ScheduledOn"),
|
||||
);
|
||||
// FE day groups use MM/DD keys (week skeleton); ignore BE weekday labels like "monday".
|
||||
const { dayGroup, dayLabel } = deriveDayFields(scheduledOn);
|
||||
const woNumber = readString(
|
||||
item,
|
||||
|
|
@ -141,7 +343,15 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
|||
"workOrderNumber",
|
||||
"WorkerOrderNumber",
|
||||
);
|
||||
const site = readString(item, "site", "Site", "locationCode", "LocationCode");
|
||||
const site = readString(
|
||||
item,
|
||||
"siteCode",
|
||||
"SiteCode",
|
||||
"site",
|
||||
"Site",
|
||||
"locationCode",
|
||||
"LocationCode",
|
||||
);
|
||||
const location = readString(item, "location", "Location", "locationName", "LocationName");
|
||||
const dispatcherId = readString(
|
||||
item,
|
||||
|
|
@ -168,8 +378,9 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
|||
const apptTime =
|
||||
readString(item, "apptTime", "ApptTime") ||
|
||||
(apptStart ? formatApptRange(apptStart, apptEnd) : "");
|
||||
const apiStatus = readString(item, "status", "Status");
|
||||
const locationIdRaw = item.locationId ?? item.LocationId;
|
||||
const vendorIdNum = readNumber(item, "vendorId", "VendorId");
|
||||
const primaryDispatchId = readNumber(item, "primaryDispatchId", "PrimaryDispatchId");
|
||||
|
||||
const row: WorkOrderTableRow = {
|
||||
id: readId(item),
|
||||
|
|
@ -179,20 +390,23 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
|||
dispatcherId,
|
||||
dispatcherName,
|
||||
scheduledOn,
|
||||
targetWeek: readString(item, "targetWeek", "TargetWeek"),
|
||||
targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")),
|
||||
dayGroup,
|
||||
dayLabel,
|
||||
location,
|
||||
locationId:
|
||||
typeof locationIdRaw === "string" || typeof locationIdRaw === "number" ? locationIdRaw : "",
|
||||
pm: readString(item, "pm", "PM", "problem", "Problem", "service", "Service"),
|
||||
pm: readString(item, "pm", "PM", "problem", "Problem", "service", "Service", "trade", "Trade"),
|
||||
serviceNotes: readString(item, "serviceNotes", "ServiceNotes", "pmNote", "PmNote"),
|
||||
extraServices: readStringArray(item, "extraServices", "ExtraServices"),
|
||||
vendorId: vendorIdNum != null ? String(vendorIdNum) : "",
|
||||
company: readString(item, "company", "Company", "vendorName", "VendorName"),
|
||||
tech: readString(item, "tech", "Tech", "technician", "Technician"),
|
||||
tech: readString(item, "techName", "TechName", "tech", "Tech", "technician", "Technician"),
|
||||
techPhone: readString(item, "techPhone", "TechPhone"),
|
||||
apptStart,
|
||||
apptEnd,
|
||||
apptTime,
|
||||
status: mapApiStatusToWizard(apiStatus),
|
||||
status: readBoardStatus(item),
|
||||
docStatus: readDocStatus(item),
|
||||
pocName: readString(item, "pocName", "PocName", "POCName"),
|
||||
pocPhone: readString(item, "pocPhone", "PocPhone", "POCPhone"),
|
||||
|
|
@ -207,11 +421,29 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
|||
mediaCount: readNumber(item, "mediaCount", "MediaCount") ?? 0,
|
||||
title: readString(item, "title", "Title", "workerOrderTitle", "WorkerOrderTitle"),
|
||||
priority: readString(item, "priority", "Priority"),
|
||||
primaryDispatchId,
|
||||
rowVersion: readRowVersion(item, "rowVersion", "RowVersion"),
|
||||
dispatchRowVersion: readRowVersion(item, "dispatchRowVersion", "DispatchRowVersion"),
|
||||
flagColor: readFlagColor(item),
|
||||
};
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function readFlagColor(record: Record<string, unknown>): string | null {
|
||||
for (const key of ["flagColor", "FlagColor"]) {
|
||||
const value = record[key];
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function mapWorkOrderTableRows(rawItems: unknown[]): WorkOrderTableRow[] {
|
||||
return rawItems.map(mapWorkOrderTableRow);
|
||||
}
|
||||
|
|
@ -232,6 +464,13 @@ export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
|
|||
|
||||
export function mapWorkOrderAuditEntry(raw: unknown): WorkOrderAuditEntry {
|
||||
const item = asRecord(raw);
|
||||
const typeRaw = readString(item, "type", "Type").toLowerCase();
|
||||
const type =
|
||||
typeRaw === "system"
|
||||
? ("system" as const)
|
||||
: typeRaw === "manual"
|
||||
? ("manual" as const)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
|
|
@ -239,8 +478,10 @@ export function mapWorkOrderAuditEntry(raw: unknown): WorkOrderAuditEntry {
|
|||
oldValue: readString(item, "oldValue", "OldValue"),
|
||||
newValue: readString(item, "newValue", "NewValue"),
|
||||
action: readString(item, "action", "Action"),
|
||||
createdAt: readString(item, "createdAt", "CreatedAt"),
|
||||
createdAt: readString(item, "createdAt", "CreatedAt", "time", "Time"),
|
||||
userName: readString(item, "userName", "UserName"),
|
||||
type,
|
||||
dispatcherId: readString(item, "dispatcherId", "DispatcherId") || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -265,6 +506,14 @@ export function mapWorkOrderDispatchSummary(raw: unknown): WorkOrderDispatchSumm
|
|||
export function mapWorkOrderDetail(raw: unknown): WorkOrderDetail {
|
||||
const wo = asRecord(raw);
|
||||
const locationId = wo.locationId ?? wo.LocationId;
|
||||
const contactIds = readOptionalIdList(wo, "contactIds", "ContactIds", "contacts", "Contacts");
|
||||
const categoryIds = readOptionalIdList(
|
||||
wo,
|
||||
"categoryIds",
|
||||
"CategoryIds",
|
||||
"categories",
|
||||
"Categories",
|
||||
);
|
||||
|
||||
return {
|
||||
id: readId(wo),
|
||||
|
|
@ -290,6 +539,8 @@ export function mapWorkOrderDetail(raw: unknown): WorkOrderDetail {
|
|||
scheduledDate: toDateInputValue(readString(wo, "scheduledDate", "ScheduledDate")),
|
||||
completedDate: toDateInputValue(readString(wo, "completedDate", "CompletedDate")),
|
||||
source: readString(wo, "source", "Source"),
|
||||
...(contactIds !== undefined ? { contactIds } : {}),
|
||||
...(categoryIds !== undefined ? { categoryIds } : {}),
|
||||
comments: Array.isArray(wo.comments)
|
||||
? wo.comments.map(mapWorkOrderComment)
|
||||
: Array.isArray(wo.Comments)
|
||||
|
|
@ -329,6 +580,8 @@ export function mapWorkOrderDetailToFormValues(detail: WorkOrderDetail): WorkOrd
|
|||
subTrade: detail.subTrade,
|
||||
vendorNTE: detail.vendorNTE,
|
||||
source: detail.source,
|
||||
...(detail.contactIds !== undefined ? { contactIds: detail.contactIds } : {}),
|
||||
...(detail.categoryIds !== undefined ? { categoryIds: detail.categoryIds } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -357,39 +610,38 @@ export function mapWorkOrderFormToCreatePayload(
|
|||
};
|
||||
}
|
||||
|
||||
export function mapWorkOrderFormToUpdatePayload(
|
||||
export function mapWorkOrderFormToUpdateFormData(
|
||||
id: string | number,
|
||||
values: WorkOrderFormValues,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
Id: id,
|
||||
WorkerOrderNumber: values.workOrderNumber,
|
||||
WorkerOrderTitle: values.title,
|
||||
Description: values.description,
|
||||
PO: values.po || null,
|
||||
TT: values.tt || null,
|
||||
AssignTo: values.assignedTo || null,
|
||||
DueDate: values.dueDate || null,
|
||||
Priority: values.priority || null,
|
||||
Status: values.status,
|
||||
LocationId: values.locationId ? parseInt(values.locationId, 10) : null,
|
||||
Problem: values.problem || null,
|
||||
Trade: values.trade || null,
|
||||
SubTrade: values.subTrade || null,
|
||||
VendorNTE: values.vendorNTE ? parseFloat(values.vendorNTE) : null,
|
||||
ScheduledDate: values.scheduledDate || null,
|
||||
CompletedDate: values.completedDate || null,
|
||||
CreatedDate: values.createdDate || null,
|
||||
Source: values.source || null,
|
||||
};
|
||||
): FormData {
|
||||
const formData = new FormData();
|
||||
formData.append("Id", String(id));
|
||||
|
||||
appendIfPresent(formData, "WorkerOrderTitle", values.title);
|
||||
appendIfPresent(formData, "Description", values.description);
|
||||
appendIfPresent(formData, "Status", mapFormStatusToEditEnum(values.status));
|
||||
appendIfPresent(formData, "Priority", mapFormPriorityToEditEnum(values.priority));
|
||||
const locationId = values.locationId ? parseInt(values.locationId, 10) : NaN;
|
||||
appendIfPresent(formData, "LocationId", Number.isNaN(locationId) ? undefined : locationId);
|
||||
appendIfPresent(formData, "AssignTo", values.assignedTo);
|
||||
appendIfPresent(formData, "DueDate", values.dueDate);
|
||||
appendIfPresent(formData, "CompletedDate", values.completedDate);
|
||||
appendIdList(formData, "ContactIds", values.contactIds);
|
||||
appendIdList(formData, "CategoryIds", values.categoryIds);
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
export function mapLocationOption(raw: unknown): LocationOption {
|
||||
const item = asRecord(raw);
|
||||
const name = readString(item, "name", "Name", "locationName", "LocationName");
|
||||
const siteCode =
|
||||
readString(item, "siteCode", "SiteCode", "code", "Code") || parseSiteCodeFromLocationName(name);
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
name: readString(item, "name", "Name", "locationName", "LocationName"),
|
||||
name,
|
||||
...(siteCode ? { siteCode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export function mapVendorDropdownItem(raw: unknown): VendorDropdownItem {
|
|||
return {
|
||||
id: readId(item),
|
||||
companyName: readString(item, "companyName", "CompanyName"),
|
||||
contactName: readString(item, "contactName", "ContactName"),
|
||||
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||
address: readString(item, "address", "Address"),
|
||||
distanceMiles: readNumber(item, "distanceMiles", "DistanceMiles"),
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
export type MockDispatcherUser = WorkOrderUser & { color: string };
|
||||
|
||||
/** UI-only dispatchers until the list filter is wired to the API. */
|
||||
export const MOCK_DISPATCHER_USERS: MockDispatcherUser[] = [
|
||||
{ id: "mock-dispatcher-sarah", name: "Sarah Chen", color: "#F59E0B" },
|
||||
{ id: "mock-dispatcher-alyssa", name: "Alyssa Reeves", color: "#0D9488" },
|
||||
{ id: "mock-dispatcher-derek", name: "Derek Walsh", color: "#6366F1" },
|
||||
{ id: "mock-dispatcher-morgan", name: "Morgan Blake", color: "#F43F5E" },
|
||||
{ id: "mock-dispatcher-james", name: "James Torres", color: "#7C3AED" },
|
||||
{ id: "mock-dispatcher-rachel", name: "Rachel Kim", color: "#0EA5E9" },
|
||||
{ id: "mock-dispatcher-tiffany", name: "Tiffany Moore", color: "#16A34A" },
|
||||
{ id: "mock-dispatcher-brandon", name: "Brandon Lee", color: "#EA580C" },
|
||||
];
|
||||
|
||||
const MOCK_DISPATCHER_COLOR_BY_ID = Object.fromEntries(
|
||||
MOCK_DISPATCHER_USERS.map((dispatcher) => [dispatcher.id, dispatcher.color]),
|
||||
) as Record<string, string>;
|
||||
|
||||
const FALLBACK_AVATAR_COLORS = [
|
||||
"#F59E0B",
|
||||
"#0D9488",
|
||||
"#6366F1",
|
||||
"#F43F5E",
|
||||
"#7C3AED",
|
||||
"#0EA5E9",
|
||||
"#16A34A",
|
||||
"#EA580C",
|
||||
];
|
||||
|
||||
function paletteColorFromKey(key: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < key.length; i += 1) {
|
||||
hash = key.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return FALLBACK_AVATAR_COLORS[Math.abs(hash) % FALLBACK_AVATAR_COLORS.length];
|
||||
}
|
||||
|
||||
export function getDispatcherAvatarColor(user: WorkOrderUser): string {
|
||||
return (
|
||||
MOCK_DISPATCHER_COLOR_BY_ID[String(user.id)] ??
|
||||
paletteColorFromKey(String(user.id || user.name))
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass";
|
||||
|
||||
/** Use seeded WO table rows instead of GetWorkOrderList (dev / auth bypass). */
|
||||
export function isWoTableMockEnabled(): boolean {
|
||||
return isAuthBypassEnabled() || import.meta.env.VITE_WO_TABLE_MOCK === "true";
|
||||
}
|
||||
|
||||
export function shouldUseMockWorkOrderDetail(id: string | number): boolean {
|
||||
return isWoTableMockEnabled() && String(id).startsWith("mock-wo-");
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { env } from "@/lib/env";
|
||||
import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass";
|
||||
|
||||
export function isWoWizardMockEnabled(): boolean {
|
||||
return env.isDev;
|
||||
}
|
||||
|
||||
export function shouldUseMockWoWizardCreate(locationId: string): boolean {
|
||||
return isAuthBypassEnabled() || locationId.startsWith("mock-");
|
||||
}
|
||||
|
|
@ -1,521 +0,0 @@
|
|||
import type {
|
||||
LocationOption,
|
||||
VendorDropdownItem,
|
||||
WorkOrderDetail,
|
||||
WorkOrderUser,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list";
|
||||
import { MOCK_DISPATCHER_USERS } from "@/domain/work-orders/mocks/dispatcher-filter-mock-data";
|
||||
import {
|
||||
MOCK_WO_WIZARD_LOCATIONS,
|
||||
MOCK_WO_WIZARD_SERVICES,
|
||||
MOCK_WO_WIZARD_VENDORS,
|
||||
} from "@/domain/work-orders/mocks/wo-wizard-mock-data";
|
||||
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||
import { deriveDayFields, isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
|
||||
import { addDaysIso, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
export const MOCK_WO_TABLE_USERS: WorkOrderUser[] = MOCK_DISPATCHER_USERS.map(({ id, name }) => ({
|
||||
id,
|
||||
name,
|
||||
}));
|
||||
|
||||
function sched(iso: string): Pick<WorkOrderTableRow, "scheduledOn" | "dayGroup" | "dayLabel"> {
|
||||
const { dayGroup, dayLabel } = deriveDayFields(iso);
|
||||
return { scheduledOn: iso, dayGroup, dayLabel };
|
||||
}
|
||||
|
||||
type RowSeed = {
|
||||
id: string | number;
|
||||
woNumber: string;
|
||||
site: string;
|
||||
location: string;
|
||||
locationId: string | number;
|
||||
type: WorkOrderTableRow["type"];
|
||||
dispatcherId: string;
|
||||
dispatcherName: string;
|
||||
scheduledOn?: string;
|
||||
targetWeek?: string;
|
||||
dueDate: string;
|
||||
pm: string;
|
||||
company: string;
|
||||
tech: string;
|
||||
techPhone: string;
|
||||
apptStart: string;
|
||||
apptEnd: string;
|
||||
apptTime: string;
|
||||
status: WorkOrderTableRow["status"];
|
||||
docStatus: WorkOrderTableRow["docStatus"];
|
||||
pocName: string;
|
||||
pocPhone: string;
|
||||
pocNotes: string;
|
||||
rescheduleCount: number;
|
||||
carriedOver: number;
|
||||
originalWeek: string;
|
||||
originalDate: string;
|
||||
scheduleWeekOnly: boolean;
|
||||
mediaCount: number;
|
||||
title?: string;
|
||||
priority?: string;
|
||||
};
|
||||
|
||||
function buildRow(seed: RowSeed): WorkOrderTableRow {
|
||||
const scheduledOn = seed.scheduledOn ?? "";
|
||||
const dayFields = scheduledOn ? sched(scheduledOn) : deriveDayFields("");
|
||||
return enrichTableRow({
|
||||
title: seed.title ?? `${seed.type || "Work Order"} — ${seed.site}`,
|
||||
priority: seed.priority ?? "Sev 3",
|
||||
...seed,
|
||||
...dayFields,
|
||||
scheduledOn,
|
||||
targetWeek: seed.targetWeek ?? "",
|
||||
isPastDue: false,
|
||||
});
|
||||
}
|
||||
|
||||
function buildMockRows(): WorkOrderTableRow[] {
|
||||
const mon = currentWeekMonday();
|
||||
const tue = addDaysIso(mon, 1);
|
||||
const wed = addDaysIso(mon, 2);
|
||||
const thu = addDaysIso(mon, 3);
|
||||
const fri = addDaysIso(mon, 4);
|
||||
const yesterday = addDaysIso(todayIso(), -1);
|
||||
|
||||
const sarah = MOCK_DISPATCHER_USERS[0];
|
||||
const alyssa = MOCK_DISPATCHER_USERS[1];
|
||||
const derek = MOCK_DISPATCHER_USERS[2];
|
||||
const morgan = MOCK_DISPATCHER_USERS[3];
|
||||
|
||||
return [
|
||||
buildRow({
|
||||
id: "mock-wo-1",
|
||||
woNumber: "20260623001",
|
||||
site: "CHI-1042",
|
||||
location: "Chicago Loop",
|
||||
locationId: "mock-loc-1",
|
||||
type: "PM",
|
||||
dispatcherId: String(sarah.id),
|
||||
dispatcherName: sarah.name,
|
||||
...sched(mon),
|
||||
dueDate: fri,
|
||||
pm: "HVAC — Quarterly PM",
|
||||
company: "Midwest HVAC Co.",
|
||||
tech: "Mike Torres",
|
||||
techPhone: "(312) 555-0198",
|
||||
apptStart: "09:00",
|
||||
apptEnd: "11:00",
|
||||
apptTime: "09:00 – 11:00",
|
||||
status: "Scheduled",
|
||||
docStatus: "No",
|
||||
pocName: "Maria Gonzalez",
|
||||
pocPhone: "(312) 555-0142",
|
||||
pocNotes: "Loading dock B",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 0,
|
||||
}),
|
||||
buildRow({
|
||||
id: "mock-wo-2",
|
||||
woNumber: "20260623002",
|
||||
site: "IND-2208",
|
||||
location: "Indianapolis North",
|
||||
locationId: "mock-loc-2",
|
||||
type: "Emergency",
|
||||
dispatcherId: String(alyssa.id),
|
||||
dispatcherName: alyssa.name,
|
||||
...sched(tue),
|
||||
dueDate: tue,
|
||||
pm: "Plumbing — Leak repair",
|
||||
company: "Great Lakes Plumbing",
|
||||
tech: "Jen Walsh",
|
||||
techPhone: "(317) 555-4412",
|
||||
apptStart: "08:00",
|
||||
apptEnd: "",
|
||||
apptTime: "08:00",
|
||||
status: "On Site",
|
||||
docStatus: "No",
|
||||
pocName: "Chris Bennett",
|
||||
pocPhone: "(317) 555-2208",
|
||||
pocNotes: "",
|
||||
rescheduleCount: 1,
|
||||
carriedOver: 0,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 3,
|
||||
}),
|
||||
buildRow({
|
||||
id: "mock-wo-3",
|
||||
woNumber: "20260623003",
|
||||
site: "MIL-3315",
|
||||
location: "Milwaukee West",
|
||||
locationId: "mock-loc-3",
|
||||
type: "Reactive",
|
||||
dispatcherId: String(derek.id),
|
||||
dispatcherName: derek.name,
|
||||
...sched(wed),
|
||||
dueDate: thu,
|
||||
pm: "Electrical — Panel inspection",
|
||||
company: "SparkLine Electric",
|
||||
tech: "Pat Nguyen",
|
||||
techPhone: "",
|
||||
apptStart: "13:00",
|
||||
apptEnd: "15:00",
|
||||
apptTime: "13:00 – 15:00",
|
||||
status: "In Progress",
|
||||
docStatus: "Yes",
|
||||
pocName: "Taylor Brooks",
|
||||
pocPhone: "(414) 555-3315",
|
||||
pocNotes: "Badge required at front desk",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 0,
|
||||
}),
|
||||
buildRow({
|
||||
id: "mock-wo-4",
|
||||
woNumber: "20260623004",
|
||||
site: "CHI-1042",
|
||||
location: "Chicago Loop",
|
||||
locationId: "mock-loc-1",
|
||||
type: "Add-On",
|
||||
dispatcherId: String(morgan.id),
|
||||
dispatcherName: morgan.name,
|
||||
...sched(thu),
|
||||
dueDate: fri,
|
||||
pm: "Roof — Membrane patch",
|
||||
company: "Midwest HVAC Co.",
|
||||
tech: "Mike Torres",
|
||||
techPhone: "(312) 555-0198",
|
||||
apptStart: "10:30",
|
||||
apptEnd: "12:00",
|
||||
apptTime: "10:30 – 12:00",
|
||||
status: "Pending",
|
||||
docStatus: "NN",
|
||||
pocName: "Maria Gonzalez",
|
||||
pocPhone: "(312) 555-0142",
|
||||
pocNotes: "",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 0,
|
||||
}),
|
||||
buildRow({
|
||||
id: "mock-wo-5",
|
||||
woNumber: "20260623005",
|
||||
site: "IND-2208",
|
||||
location: "Indianapolis North",
|
||||
locationId: "mock-loc-2",
|
||||
type: "PM",
|
||||
dispatcherId: "",
|
||||
dispatcherName: "",
|
||||
...sched(fri),
|
||||
dueDate: fri,
|
||||
pm: "HVAC — Quarterly PM",
|
||||
company: "",
|
||||
tech: "",
|
||||
techPhone: "",
|
||||
apptStart: "",
|
||||
apptEnd: "",
|
||||
apptTime: "",
|
||||
status: "Incomplete",
|
||||
docStatus: "No",
|
||||
pocName: "",
|
||||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 0,
|
||||
}),
|
||||
buildRow({
|
||||
id: "mock-wo-6",
|
||||
woNumber: "20260623006",
|
||||
site: "MIL-3315",
|
||||
location: "Milwaukee West",
|
||||
locationId: "mock-loc-3",
|
||||
type: "Overdue",
|
||||
dispatcherId: String(sarah.id),
|
||||
dispatcherName: sarah.name,
|
||||
...sched(mon),
|
||||
dueDate: yesterday,
|
||||
pm: "Plumbing — Leak repair",
|
||||
company: "Great Lakes Plumbing",
|
||||
tech: "Jen Walsh",
|
||||
techPhone: "(317) 555-4412",
|
||||
apptStart: "07:00",
|
||||
apptEnd: "09:00",
|
||||
apptTime: "07:00 – 09:00",
|
||||
status: "Scheduled",
|
||||
docStatus: "No",
|
||||
pocName: "Taylor Brooks",
|
||||
pocPhone: "(414) 555-3315",
|
||||
pocNotes: "",
|
||||
rescheduleCount: 2,
|
||||
carriedOver: 1,
|
||||
originalWeek: "",
|
||||
originalDate: yesterday,
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 0,
|
||||
}),
|
||||
buildRow({
|
||||
id: "mock-wo-7",
|
||||
woNumber: "20260623007",
|
||||
site: "CHI-1042",
|
||||
location: "Chicago Loop",
|
||||
locationId: "mock-loc-1",
|
||||
type: "Reactive",
|
||||
dispatcherId: String(alyssa.id),
|
||||
dispatcherName: alyssa.name,
|
||||
scheduledOn: "",
|
||||
targetWeek: mon,
|
||||
dueDate: wed,
|
||||
pm: "Electrical — Panel inspection",
|
||||
company: "SparkLine Electric",
|
||||
tech: "Pat Nguyen",
|
||||
techPhone: "",
|
||||
apptStart: "",
|
||||
apptEnd: "",
|
||||
apptTime: "",
|
||||
status: "Incomplete",
|
||||
docStatus: "No",
|
||||
pocName: "Maria Gonzalez",
|
||||
pocPhone: "(312) 555-0142",
|
||||
pocNotes: "Week-only target",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
originalWeek: mon,
|
||||
originalDate: "",
|
||||
scheduleWeekOnly: true,
|
||||
mediaCount: 0,
|
||||
}),
|
||||
buildRow({
|
||||
id: "mock-wo-8",
|
||||
woNumber: "20260623008",
|
||||
site: "IND-2208",
|
||||
location: "Indianapolis North",
|
||||
locationId: "mock-loc-2",
|
||||
type: "PM",
|
||||
dispatcherId: String(derek.id),
|
||||
dispatcherName: derek.name,
|
||||
scheduledOn: "",
|
||||
targetWeek: "",
|
||||
dueDate: thu,
|
||||
pm: "HVAC — Quarterly PM",
|
||||
company: "Midwest HVAC Co.",
|
||||
tech: "Mike Torres",
|
||||
techPhone: "(312) 555-0198",
|
||||
apptStart: "",
|
||||
apptEnd: "",
|
||||
apptTime: "",
|
||||
status: "Pending",
|
||||
docStatus: "No",
|
||||
pocName: "Chris Bennett",
|
||||
pocPhone: "(317) 555-2208",
|
||||
pocNotes: "",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 0,
|
||||
}),
|
||||
buildRow({
|
||||
id: "mock-wo-9",
|
||||
woNumber: "20260623009",
|
||||
site: "MIL-3315",
|
||||
location: "Milwaukee West",
|
||||
locationId: "mock-loc-3",
|
||||
type: "PM",
|
||||
dispatcherId: String(morgan.id),
|
||||
dispatcherName: morgan.name,
|
||||
...sched(wed),
|
||||
dueDate: fri,
|
||||
pm: "Roof — Membrane patch",
|
||||
company: "SparkLine Electric",
|
||||
tech: "Pat Nguyen",
|
||||
techPhone: "",
|
||||
apptStart: "14:00",
|
||||
apptEnd: "16:00",
|
||||
apptTime: "14:00 – 16:00",
|
||||
status: "En Route",
|
||||
docStatus: "No",
|
||||
pocName: "Taylor Brooks",
|
||||
pocPhone: "(414) 555-3315",
|
||||
pocNotes: "",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 0,
|
||||
}),
|
||||
buildRow({
|
||||
id: "mock-wo-10",
|
||||
woNumber: "20260623010",
|
||||
site: "CHI-1042",
|
||||
location: "Chicago Loop",
|
||||
locationId: "mock-loc-1",
|
||||
type: "Reactive",
|
||||
dispatcherId: String(sarah.id),
|
||||
dispatcherName: sarah.name,
|
||||
...sched(tue),
|
||||
dueDate: wed,
|
||||
pm: "Plumbing — Leak repair",
|
||||
company: "Great Lakes Plumbing",
|
||||
tech: "Jen Walsh",
|
||||
techPhone: "(317) 555-4412",
|
||||
apptStart: "11:00",
|
||||
apptEnd: "12:30",
|
||||
apptTime: "11:00 – 12:30",
|
||||
status: "Completed",
|
||||
docStatus: "Yes",
|
||||
pocName: "Maria Gonzalez",
|
||||
pocPhone: "(312) 555-0142",
|
||||
pocNotes: "",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 2,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
let cachedRows: WorkOrderTableRow[] | null = null;
|
||||
|
||||
export function getMockWorkOrderTableRows(): WorkOrderTableRow[] {
|
||||
if (!cachedRows) {
|
||||
cachedRows = buildMockRows();
|
||||
}
|
||||
return cachedRows;
|
||||
}
|
||||
|
||||
function rowMatchesSearch(row: WorkOrderTableRow, search: string): boolean {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [
|
||||
row.site,
|
||||
row.location,
|
||||
row.woNumber,
|
||||
row.type,
|
||||
row.dispatcherName,
|
||||
row.pm,
|
||||
row.company,
|
||||
row.tech,
|
||||
row.status,
|
||||
row.title,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
}
|
||||
|
||||
export function getMockWorkOrdersList(params: WorkOrdersListParams = {}): {
|
||||
items: WorkOrderTableRow[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
} {
|
||||
let items = getMockWorkOrderTableRows();
|
||||
|
||||
if (params.search) {
|
||||
items = items.filter((row) => rowMatchesSearch(row, params.search!));
|
||||
}
|
||||
|
||||
if (params.type) {
|
||||
items = items.filter((row) => row.type === params.type);
|
||||
}
|
||||
|
||||
if (params.dateFrom && params.dateTo) {
|
||||
const weekMonday = params.dateFrom;
|
||||
items = items.filter((row) => {
|
||||
if (row.scheduledOn) {
|
||||
return row.scheduledOn >= params.dateFrom! && row.scheduledOn <= params.dateTo!;
|
||||
}
|
||||
if (row.targetWeek) {
|
||||
return isDateInWeek(row.targetWeek, weekMonday);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const page = params.page ?? 1;
|
||||
const pageSize = params.pageSize ?? items.length;
|
||||
|
||||
return {
|
||||
items,
|
||||
totalCount: items.length,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockWorkOrderTableRow(id: string | number): WorkOrderTableRow | undefined {
|
||||
return getMockWorkOrderTableRows().find((row) => String(row.id) === String(id));
|
||||
}
|
||||
|
||||
export function buildMockWorkOrderDetailFromTableRow(row: WorkOrderTableRow): WorkOrderDetail {
|
||||
return {
|
||||
id: row.id,
|
||||
internalWONumber: row.woNumber,
|
||||
workOrderNumber: row.woNumber,
|
||||
title: row.title,
|
||||
location: row.location,
|
||||
locationId: String(row.locationId),
|
||||
locationZip: "",
|
||||
assignedTo: row.dispatcherName,
|
||||
dueDate: row.dueDate,
|
||||
createdDate: addDaysIso(todayIso(), -7),
|
||||
createdDateFull: new Date().toISOString(),
|
||||
description: [
|
||||
row.pocNotes && `POC notes: ${row.pocNotes}`,
|
||||
row.pocName && `POC: ${row.pocName} · ${row.pocPhone}`,
|
||||
row.apptTime && row.apptTime !== "—" && `Appt: ${row.apptTime}`,
|
||||
row.company && `Vendor: ${row.company}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
priority: row.priority,
|
||||
status:
|
||||
row.status === "Completed" ? "Done" : row.status === "Incomplete" ? "Open" : "In Progress",
|
||||
po: "",
|
||||
tt: "",
|
||||
problem: row.pm,
|
||||
trade: "",
|
||||
subTrade: "",
|
||||
vendorNTE: "",
|
||||
scheduledDate: row.scheduledOn,
|
||||
completedDate: row.status === "Completed" ? todayIso() : "",
|
||||
source: row.type,
|
||||
comments: [],
|
||||
auditLog: [],
|
||||
dispatches: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockWorkOrderLocations(): LocationOption[] {
|
||||
return MOCK_WO_WIZARD_LOCATIONS;
|
||||
}
|
||||
|
||||
export function getMockWorkOrderVendors(): VendorDropdownItem[] {
|
||||
return MOCK_WO_WIZARD_VENDORS;
|
||||
}
|
||||
|
||||
export function getMockPmTypes(): string[] {
|
||||
return MOCK_WO_WIZARD_SERVICES.map((s) => s.value).filter(Boolean);
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
import type { Location } from "@/domain/locations/types/location";
|
||||
import type { DropdownOption } from "@/domain/settings/dropdown-options/types/dropdown-option";
|
||||
import type {
|
||||
LocationOption,
|
||||
VendorDropdownItem,
|
||||
WorkOrderDetail,
|
||||
WorkOrderFormValues,
|
||||
WorkOrderUser,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
|
||||
export const MOCK_WO_WIZARD_USERS: WorkOrderUser[] = [
|
||||
{ id: "mock-user-1", name: "Alex Rivera" },
|
||||
{ id: "mock-user-2", name: "Jordan Kim" },
|
||||
{ id: "mock-user-3", name: "Sam Patel" },
|
||||
];
|
||||
|
||||
export const MOCK_WO_WIZARD_LOCATIONS: LocationOption[] = [
|
||||
{ id: "mock-loc-1", name: "CHI-1042 — Chicago Loop" },
|
||||
{ id: "mock-loc-2", name: "IND-2208 — Indianapolis North" },
|
||||
{ id: "mock-loc-3", name: "MIL-3315 — Milwaukee West" },
|
||||
];
|
||||
|
||||
const MOCK_LOCATION_DETAILS: Record<string, Location> = {
|
||||
"mock-loc-1": {
|
||||
id: "mock-loc-1",
|
||||
name: "CHI-1042 — Chicago Loop",
|
||||
address: "233 S Wacker Dr",
|
||||
city: "Chicago",
|
||||
state: "IL",
|
||||
zipCode: "60606",
|
||||
phone: "(312) 555-0142",
|
||||
contact: "Maria Gonzalez",
|
||||
contactEmail: "maria.gonzalez@example.com",
|
||||
status: "Active",
|
||||
},
|
||||
"mock-loc-2": {
|
||||
id: "mock-loc-2",
|
||||
name: "IND-2208 — Indianapolis North",
|
||||
address: "8702 Keystone Crossing",
|
||||
city: "Indianapolis",
|
||||
state: "IN",
|
||||
zipCode: "46240",
|
||||
phone: "(317) 555-2208",
|
||||
contact: "Chris Bennett",
|
||||
contactEmail: "chris.bennett@example.com",
|
||||
status: "Active",
|
||||
},
|
||||
"mock-loc-3": {
|
||||
id: "mock-loc-3",
|
||||
name: "MIL-3315 — Milwaukee West",
|
||||
address: "411 E Wisconsin Ave",
|
||||
city: "Milwaukee",
|
||||
state: "WI",
|
||||
zipCode: "53202",
|
||||
phone: "(414) 555-3315",
|
||||
contact: "Taylor Brooks",
|
||||
contactEmail: "taylor.brooks@example.com",
|
||||
status: "Active",
|
||||
},
|
||||
};
|
||||
|
||||
export const MOCK_WO_WIZARD_SERVICES: DropdownOption[] = [
|
||||
{
|
||||
id: "mock-svc-1",
|
||||
category: "Problem",
|
||||
value: "HVAC — Quarterly PM",
|
||||
parentValue: null,
|
||||
sortOrder: 1,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "mock-svc-2",
|
||||
category: "Problem",
|
||||
value: "Plumbing — Leak repair",
|
||||
parentValue: null,
|
||||
sortOrder: 2,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "mock-svc-3",
|
||||
category: "Problem",
|
||||
value: "Electrical — Panel inspection",
|
||||
parentValue: null,
|
||||
sortOrder: 3,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "mock-svc-4",
|
||||
category: "Problem",
|
||||
value: "Roof — Membrane patch",
|
||||
parentValue: null,
|
||||
sortOrder: 4,
|
||||
isActive: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_WO_WIZARD_VENDORS: VendorDropdownItem[] = [
|
||||
{
|
||||
id: "mock-vendor-1",
|
||||
companyName: "Midwest HVAC Co.",
|
||||
tradeSpecialties: "HVAC",
|
||||
address: "1200 Industrial Pkwy, Chicago, IL 60632",
|
||||
distanceMiles: 4.2,
|
||||
},
|
||||
{
|
||||
id: "mock-vendor-2",
|
||||
companyName: "Great Lakes Plumbing",
|
||||
tradeSpecialties: "Plumbing",
|
||||
address: "455 Water St, Milwaukee, WI 53202",
|
||||
distanceMiles: 8.7,
|
||||
},
|
||||
{
|
||||
id: "mock-vendor-3",
|
||||
companyName: "SparkLine Electric",
|
||||
tradeSpecialties: "Electrical",
|
||||
address: "88 Volt Ave, Indianapolis, IN 46225",
|
||||
distanceMiles: 12.1,
|
||||
},
|
||||
];
|
||||
|
||||
export function getMockWoWizardLocationDetail(locationId: string): Location | undefined {
|
||||
return MOCK_LOCATION_DETAILS[locationId];
|
||||
}
|
||||
|
||||
export function buildMockWorkOrderDetail(values: WorkOrderFormValues): WorkOrderDetail {
|
||||
const id = `mock-wo-${Date.now()}`;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return {
|
||||
id,
|
||||
internalWONumber: values.internalWONumber || id,
|
||||
workOrderNumber: values.workOrderNumber || "MOCK-0001",
|
||||
title: values.title || "Mock Work Order",
|
||||
location:
|
||||
MOCK_WO_WIZARD_LOCATIONS.find((l) => String(l.id) === values.locationId)?.name ?? "Mock Site",
|
||||
locationId: values.locationId,
|
||||
locationZip: getMockWoWizardLocationDetail(values.locationId)?.zipCode ?? "",
|
||||
assignedTo: values.assignedTo,
|
||||
dueDate: values.dueDate,
|
||||
createdDate: values.createdDate,
|
||||
createdDateFull: now,
|
||||
description: values.description,
|
||||
priority: values.priority,
|
||||
status: values.status,
|
||||
po: values.po,
|
||||
tt: values.tt,
|
||||
problem: values.problem,
|
||||
trade: values.trade,
|
||||
subTrade: values.subTrade,
|
||||
vendorNTE: values.vendorNTE,
|
||||
scheduledDate: values.scheduledDate,
|
||||
completedDate: values.completedDate,
|
||||
source: values.source,
|
||||
comments: [],
|
||||
auditLog: [],
|
||||
dispatches: [],
|
||||
};
|
||||
}
|
||||
|
|
@ -31,6 +31,8 @@ export const workOrderFormSchema = z.object({
|
|||
subTrade: z.string(),
|
||||
vendorNTE: z.string(),
|
||||
source: z.string(),
|
||||
contactIds: z.array(z.number()).optional(),
|
||||
categoryIds: z.array(z.number()).optional(),
|
||||
});
|
||||
|
||||
export type WorkOrderFormSchemaValues = z.infer<typeof workOrderFormSchema>;
|
||||
|
|
|
|||
167
src/domain/work-orders/types/work-order-board-detail.ts
Normal file
167
src/domain/work-orders/types/work-order-board-detail.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import type { WorkOrderAuditEntry, WorkOrderComment } from "./work-order-detail";
|
||||
import type { CompletionDocStatus, WorkOrderTableRow } from "./work-order-table-row";
|
||||
|
||||
/** API media.category: 1=Before, 2=After, 3=Extra, 4=Completion */
|
||||
export const BOARD_MEDIA_CATEGORY_API = {
|
||||
Before: 1,
|
||||
After: 2,
|
||||
Extra: 3,
|
||||
Completion: 4,
|
||||
} as const;
|
||||
|
||||
export type BoardMediaCategoryApi =
|
||||
(typeof BOARD_MEDIA_CATEGORY_API)[keyof typeof BOARD_MEDIA_CATEGORY_API];
|
||||
|
||||
export type BoardMediaCategory = "Before" | "After" | "Extra" | "Completion";
|
||||
|
||||
export interface WorkOrderBoardDetailInfoApi {
|
||||
id: number;
|
||||
woNumber?: string | null;
|
||||
rescheduleCount?: number;
|
||||
carriedOver?: number;
|
||||
workOrderType?: number | null;
|
||||
isPastDue?: boolean;
|
||||
siteCode?: string | null;
|
||||
locationName?: string | null;
|
||||
pocName?: string | null;
|
||||
pocPhone?: string | null;
|
||||
pocNotes?: string | null;
|
||||
lifecycleStatus?: number | null;
|
||||
lifecycleStatusLabel?: string | null;
|
||||
legacyStatus?: string | null;
|
||||
dispatcherId?: string | null;
|
||||
dispatcherName?: string | null;
|
||||
initials?: string | null;
|
||||
color?: string | null;
|
||||
dueDate?: string | null;
|
||||
scheduledDate?: string | null;
|
||||
targetWeek?: string | null;
|
||||
scheduleWeekOnly?: boolean | null;
|
||||
dayGroup?: string | null;
|
||||
vendorId?: number | null;
|
||||
vendorName?: string | null;
|
||||
techName?: string | null;
|
||||
techPhone?: string | null;
|
||||
apptDate?: string | null;
|
||||
apptTime?: string | null;
|
||||
pm?: string | null;
|
||||
serviceNotes?: string | null;
|
||||
extraServices?: string[] | null;
|
||||
docStatus?: number | null;
|
||||
flagColor?: string | null;
|
||||
primaryDispatchId?: number | null;
|
||||
rowVersion?: string | null;
|
||||
dispatchRowVersion?: string | null;
|
||||
description?: string | null;
|
||||
trade?: string | null;
|
||||
problem?: string | null;
|
||||
subTrade?: string | null;
|
||||
originalWeek?: string | null;
|
||||
originalDate?: string | null;
|
||||
completedDate?: string | null;
|
||||
vendorNotes?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkOrderBoardCompletionTemplateApi {
|
||||
id: number;
|
||||
name?: string | null;
|
||||
serviceKey?: string | null;
|
||||
workOrderType?: number | null;
|
||||
templateUrl?: string | null;
|
||||
isActive?: boolean | null;
|
||||
}
|
||||
|
||||
export interface WorkOrderBoardDispatchSignoffApi {
|
||||
dispatchId?: number | null;
|
||||
signoffType?: string | null;
|
||||
name?: string | null;
|
||||
signedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkOrderBoardCompletionApi {
|
||||
docStatus?: number | null;
|
||||
template?: WorkOrderBoardCompletionTemplateApi | null;
|
||||
signOffName?: string | null;
|
||||
signOffAttachment?: string | null;
|
||||
signOffSignature?: string | null;
|
||||
dispatchSignoffs?: WorkOrderBoardDispatchSignoffApi[] | null;
|
||||
}
|
||||
|
||||
export interface WorkOrderBoardDetailCommentApi {
|
||||
id?: number | null;
|
||||
authorId?: string | null;
|
||||
authorName?: string | null;
|
||||
text?: string | null;
|
||||
time?: string | null;
|
||||
documents?: unknown;
|
||||
}
|
||||
|
||||
export interface WorkOrderBoardDetailAuditApi {
|
||||
type?: string | null;
|
||||
dispatcherId?: string | null;
|
||||
action?: string | null;
|
||||
fieldName?: string | null;
|
||||
oldValue?: string | null;
|
||||
newValue?: string | null;
|
||||
time?: string | null;
|
||||
dispatchId?: number | null;
|
||||
}
|
||||
|
||||
export interface WorkOrderBoardDetailMediaApi {
|
||||
id?: number | null;
|
||||
category?: number | null;
|
||||
url?: string | null;
|
||||
fileName?: string | null;
|
||||
uploadedAt?: string | null;
|
||||
isLegacy?: boolean | null;
|
||||
}
|
||||
|
||||
export interface WorkOrderBoardDetailApi {
|
||||
info: WorkOrderBoardDetailInfoApi;
|
||||
completion?: WorkOrderBoardCompletionApi | null;
|
||||
comments?: WorkOrderBoardDetailCommentApi[] | null;
|
||||
audit?: WorkOrderBoardDetailAuditApi[] | null;
|
||||
media?: WorkOrderBoardDetailMediaApi[] | null;
|
||||
}
|
||||
|
||||
export interface WorkOrderBoardCompletionTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
serviceKey: string;
|
||||
workOrderType: number | null;
|
||||
templateUrl: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface WorkOrderBoardDispatchSignoff {
|
||||
dispatchId: number | null;
|
||||
signoffType: string;
|
||||
name: string;
|
||||
signedAt: string;
|
||||
}
|
||||
|
||||
export interface WorkOrderBoardCompletion {
|
||||
docStatus: CompletionDocStatus;
|
||||
template: WorkOrderBoardCompletionTemplate | null;
|
||||
signOffName: string;
|
||||
signOffAttachment: string;
|
||||
signOffSignature: string;
|
||||
dispatchSignoffs: WorkOrderBoardDispatchSignoff[];
|
||||
}
|
||||
|
||||
export interface WorkOrderBoardMediaItem {
|
||||
id: string;
|
||||
category: BoardMediaCategory;
|
||||
url: string;
|
||||
fileName: string;
|
||||
uploadedAt: string;
|
||||
isLegacy: boolean;
|
||||
}
|
||||
|
||||
export interface WorkOrderBoardDetail {
|
||||
info: WorkOrderTableRow;
|
||||
completion: WorkOrderBoardCompletion;
|
||||
comments: WorkOrderComment[];
|
||||
audit: WorkOrderAuditEntry[];
|
||||
media: WorkOrderBoardMediaItem[];
|
||||
}
|
||||
254
src/domain/work-orders/types/work-order-board.ts
Normal file
254
src/domain/work-orders/types/work-order-board.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import type { DateRangeKey } from "./work-order-advanced-filters";
|
||||
import type { WorkOrderTableRow } from "./work-order-table-row";
|
||||
import type { WizardWOStatus, WOType } from "./work-order-wizard";
|
||||
|
||||
/** Numeric WorkOrderType enum as expected by POST /workorders/board (no string enums). */
|
||||
export const WORK_ORDER_TYPE_API = {
|
||||
PO: 1,
|
||||
PM: 2,
|
||||
Emergency: 3,
|
||||
Project: 4,
|
||||
Inspection: 5,
|
||||
Reactive: 6,
|
||||
AddOn: 7,
|
||||
Other: 99,
|
||||
} as const;
|
||||
|
||||
export type WorkOrderTypeApi = (typeof WORK_ORDER_TYPE_API)[keyof typeof WORK_ORDER_TYPE_API];
|
||||
|
||||
/** Numeric DocStatus enum. */
|
||||
export const DOC_STATUS_API = {
|
||||
Yes: 1,
|
||||
No: 2,
|
||||
NotNeeded: 3,
|
||||
} as const;
|
||||
|
||||
export type DocStatusApi = (typeof DOC_STATUS_API)[keyof typeof DOC_STATUS_API];
|
||||
|
||||
/** Numeric LifecycleStatus enum aligned with SHOC board. */
|
||||
export const LIFECYCLE_STATUS_API = {
|
||||
Incomplete: 1,
|
||||
Pending: 2,
|
||||
Scheduled: 3,
|
||||
"En Route": 4,
|
||||
"On Site": 5,
|
||||
"In Progress": 6,
|
||||
Completed: 7,
|
||||
Rescheduled: 8,
|
||||
Canceled: 9,
|
||||
"Pending Quote": 10,
|
||||
} as const;
|
||||
|
||||
export type LifecycleStatusApi = (typeof LIFECYCLE_STATUS_API)[keyof typeof LIFECYCLE_STATUS_API];
|
||||
|
||||
export const WORK_ORDER_TYPE_API_TO_FE: Record<number, WOType | ""> = {
|
||||
[WORK_ORDER_TYPE_API.PM]: "PM",
|
||||
[WORK_ORDER_TYPE_API.Reactive]: "Reactive",
|
||||
[WORK_ORDER_TYPE_API.Emergency]: "Emergency",
|
||||
[WORK_ORDER_TYPE_API.AddOn]: "Add-On",
|
||||
};
|
||||
|
||||
export const LIFECYCLE_STATUS_API_TO_FE: Record<number, WizardWOStatus | "Canceled"> = {
|
||||
[LIFECYCLE_STATUS_API.Incomplete]: "Incomplete",
|
||||
[LIFECYCLE_STATUS_API.Pending]: "Pending",
|
||||
[LIFECYCLE_STATUS_API.Scheduled]: "Scheduled",
|
||||
[LIFECYCLE_STATUS_API["En Route"]]: "En Route",
|
||||
[LIFECYCLE_STATUS_API["On Site"]]: "On Site",
|
||||
[LIFECYCLE_STATUS_API["In Progress"]]: "In Progress",
|
||||
[LIFECYCLE_STATUS_API.Completed]: "Completed",
|
||||
[LIFECYCLE_STATUS_API.Rescheduled]: "Rescheduled",
|
||||
[LIFECYCLE_STATUS_API.Canceled]: "Canceled",
|
||||
[LIFECYCLE_STATUS_API["Pending Quote"]]: "Pending Quote",
|
||||
};
|
||||
|
||||
export interface WorkOrderBoardCreatePayload {
|
||||
workOrderType: WorkOrderTypeApi;
|
||||
siteCode: string;
|
||||
woNumber?: string;
|
||||
assignTo?: string;
|
||||
dueDate?: string;
|
||||
scheduledDate?: string;
|
||||
targetWeek?: string;
|
||||
scheduleWeekOnly?: boolean;
|
||||
vendorId?: number;
|
||||
apptDate?: string;
|
||||
apptTime?: string;
|
||||
docStatus?: DocStatusApi;
|
||||
/** Legacy/general text only — do not embed POC/vendor/service notes here. */
|
||||
description?: string;
|
||||
/** @deprecated Prefer `primaryService`. Kept for temporary API compat. */
|
||||
trade?: string;
|
||||
/** Primary service label; persisted as board `pm`. */
|
||||
primaryService?: string;
|
||||
/** Services beyond primary; order preserved. */
|
||||
extraServices?: string[];
|
||||
/** Issue detail from Service & Notes (`pmNote` in draft). */
|
||||
serviceNotes?: string;
|
||||
locationId?: number;
|
||||
pocContactId?: number;
|
||||
pocName?: string;
|
||||
pocPhone?: string;
|
||||
pocNotes?: string;
|
||||
techPhone?: string;
|
||||
vendorNotes?: string;
|
||||
}
|
||||
|
||||
/** Payload for PATCH /workorders/{id}/board (one field per request). */
|
||||
export interface WorkOrderBoardPatchPayload {
|
||||
field: string;
|
||||
value?: string | null;
|
||||
workOrderVersion: string;
|
||||
dispatchVersion?: string | null;
|
||||
primaryDispatchId?: number | null;
|
||||
}
|
||||
|
||||
export type WorkOrderBoardEditableField =
|
||||
| "woNumber"
|
||||
| "workOrderType"
|
||||
| "siteCode"
|
||||
| "lifecycleStatus"
|
||||
| "assignTo"
|
||||
| "dueDate"
|
||||
| "scheduledDate"
|
||||
| "targetWeek"
|
||||
| "scheduleWeekOnly"
|
||||
| "vendorId"
|
||||
| "apptDate"
|
||||
| "apptTime"
|
||||
| "docStatus"
|
||||
| "pm"
|
||||
| "serviceNotes"
|
||||
| "extraServices"
|
||||
| "flagColor";
|
||||
|
||||
/** Espelha WorkOrderBoardRowDto (JSON camelCase). */
|
||||
export interface WorkOrderBoardRowApi {
|
||||
id: number;
|
||||
woNumber?: string | null;
|
||||
rescheduleCount?: number;
|
||||
carriedOver?: number;
|
||||
workOrderType?: number | null;
|
||||
isPastDue?: boolean;
|
||||
siteCode?: string | null;
|
||||
locationName?: string | null;
|
||||
pocName?: string | null;
|
||||
pocPhone?: string | null;
|
||||
pocNotes?: string | null;
|
||||
lifecycleStatus?: number | null;
|
||||
lifecycleStatusLabel?: string | null;
|
||||
legacyStatus?: string | null;
|
||||
dispatcherId?: string | null;
|
||||
dispatcherName?: string | null;
|
||||
initials?: string | null;
|
||||
/** Dispatcher avatar color — not the WO flag. */
|
||||
color?: string | null;
|
||||
/** Board flag color (#RRGGBB) or null when cleared. */
|
||||
flagColor?: string | null;
|
||||
dueDate?: string | null;
|
||||
scheduledDate?: string | null;
|
||||
targetWeek?: string | null;
|
||||
scheduleWeekOnly?: boolean | null;
|
||||
dayGroup?: string | null;
|
||||
vendorId?: number | null;
|
||||
vendorName?: string | null;
|
||||
techName?: string | null;
|
||||
techPhone?: string | null;
|
||||
apptDate?: string | null;
|
||||
apptTime?: string | null;
|
||||
pm?: string | null;
|
||||
serviceNotes?: string | null;
|
||||
extraServices?: string[] | null;
|
||||
docStatus?: number | null;
|
||||
primaryDispatchId?: number | null;
|
||||
rowVersion?: string | null;
|
||||
dispatchRowVersion?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkOrderBoardCountsApi {
|
||||
returned: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Response de GET /board */
|
||||
export interface WorkOrderBoardResponseApi {
|
||||
weekStart: string;
|
||||
weekEnd: string;
|
||||
counts: WorkOrderBoardCountsApi;
|
||||
unscheduled: WorkOrderBoardRowApi[];
|
||||
scheduled: WorkOrderBoardRowApi[];
|
||||
}
|
||||
|
||||
/** Response de GET /board/search — PagedResult<T> */
|
||||
export interface PagedResultApi<T> {
|
||||
items: T[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages?: number;
|
||||
hasPrevious?: boolean;
|
||||
hasNext?: boolean;
|
||||
}
|
||||
|
||||
export type WorkOrderBoardSearchResponseApi = PagedResultApi<WorkOrderBoardRowApi>;
|
||||
|
||||
export type BoardDatePresetApi =
|
||||
| "ThisWeek"
|
||||
| "LastWeek"
|
||||
| "ThisMonth"
|
||||
| "Last3Months"
|
||||
| "NextWeek"
|
||||
| "NextMonth"
|
||||
| "Custom";
|
||||
|
||||
export const DATE_RANGE_TO_PRESET: Record<DateRangeKey, BoardDatePresetApi> = {
|
||||
"this-week": "ThisWeek",
|
||||
"last-week": "LastWeek",
|
||||
"this-month": "ThisMonth",
|
||||
"last-3-months": "Last3Months",
|
||||
"next-week": "NextWeek",
|
||||
"next-month": "NextMonth",
|
||||
custom: "Custom",
|
||||
};
|
||||
|
||||
/** BE board uses `__unassigned__`; legacy list used `__unassigned`. */
|
||||
export const BOARD_DISPATCHER_UNASSIGNED = "__unassigned__";
|
||||
|
||||
export interface WorkOrderBoardQueryParams {
|
||||
weekStart: string;
|
||||
weekEnd?: string;
|
||||
dispatchers?: string[];
|
||||
myWorkOrders?: boolean;
|
||||
types?: number[];
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface WorkOrderBoardSearchQueryParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
datePreset?: BoardDatePresetApi;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
sites?: string[];
|
||||
types?: number[];
|
||||
dispatchers?: string[];
|
||||
statuses?: number[];
|
||||
pmTypes?: string[];
|
||||
vendorIds?: number[];
|
||||
docStatuses?: number[];
|
||||
myWorkOrders?: boolean;
|
||||
sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string;
|
||||
sortDir?: "asc" | "desc" | string;
|
||||
}
|
||||
|
||||
export interface WorkOrderBoardListResult {
|
||||
items: WorkOrderTableRow[];
|
||||
scheduled: WorkOrderTableRow[];
|
||||
unscheduled: WorkOrderTableRow[];
|
||||
totalCount: number;
|
||||
returnedCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
weekStart?: string;
|
||||
weekEnd?: string;
|
||||
}
|
||||
|
|
@ -26,6 +26,9 @@ export type WorkOrderCommentType = "customer" | "internal";
|
|||
export interface LocationOption {
|
||||
id: string | number;
|
||||
name: string;
|
||||
siteCode?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
export interface EmployeeOption {
|
||||
|
|
@ -36,6 +39,7 @@ export interface EmployeeOption {
|
|||
export interface VendorDropdownItem {
|
||||
id: string | number;
|
||||
companyName: string;
|
||||
contactName: string;
|
||||
tradeSpecialties: string;
|
||||
address: string;
|
||||
distanceMiles: number | null;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ export interface WorkOrderComment {
|
|||
commenttext: string;
|
||||
commentType: WorkOrderCommentType | string;
|
||||
userName: string;
|
||||
/** Author user id from board comments API — used for ownership checks. */
|
||||
authorId?: string | null;
|
||||
commenter: string | null;
|
||||
recordType: string | null;
|
||||
}
|
||||
|
|
@ -19,6 +21,9 @@ export interface WorkOrderAuditEntry {
|
|||
action: string;
|
||||
createdAt: string;
|
||||
userName: string;
|
||||
/** Board detail audit shape: system vs manual (matches design WOSlideOver). */
|
||||
type?: "manual" | "system";
|
||||
dispatcherId?: string;
|
||||
}
|
||||
|
||||
export interface WorkOrderDetail {
|
||||
|
|
@ -45,6 +50,10 @@ export interface WorkOrderDetail {
|
|||
scheduledDate: string;
|
||||
completedDate: string;
|
||||
source: string;
|
||||
/** Present when GetById returns contacts; used to re-send on edit so backend does not wipe. */
|
||||
contactIds?: number[];
|
||||
/** Present when GetById returns categories; used to re-send on edit so backend does not wipe. */
|
||||
categoryIds?: number[];
|
||||
comments: WorkOrderComment[];
|
||||
auditLog: WorkOrderAuditEntry[];
|
||||
dispatches: WorkOrderDispatchSummary[];
|
||||
|
|
@ -70,6 +79,10 @@ export interface WorkOrderFormValues {
|
|||
subTrade: string;
|
||||
vendorNTE: string;
|
||||
source: string;
|
||||
/** When set (from detail), re-sent on edit to avoid wiping contacts. */
|
||||
contactIds?: number[];
|
||||
/** When set (from detail), re-sent on edit to avoid wiping categories. */
|
||||
categoryIds?: number[];
|
||||
}
|
||||
|
||||
export function createDefaultWorkOrderFormValues(): WorkOrderFormValues {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ export interface MediaFile {
|
|||
fileType: FileCategory;
|
||||
url: string;
|
||||
category: MediaCategory;
|
||||
/** True when media is a legacy Before/After/Completion column (not deletable). */
|
||||
isLegacy?: boolean;
|
||||
}
|
||||
|
||||
export const MEDIA_CATEGORIES: MediaCategory[] = [
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { WizardWOStatus, WOType } from "./work-order-wizard";
|
|||
|
||||
export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
|
||||
|
||||
export const WORK_ORDERS_TABLE_PAGE_SIZE = 500;
|
||||
export const WORK_ORDERS_TABLE_PAGE_SIZE = 100;
|
||||
|
||||
export interface WorkOrderTableRow {
|
||||
id: string | number;
|
||||
|
|
@ -18,13 +18,18 @@ export interface WorkOrderTableRow {
|
|||
location: string;
|
||||
locationId: string | number;
|
||||
pm: string;
|
||||
/** Issue detail from Service & Notes. */
|
||||
serviceNotes: string;
|
||||
/** Additional services beyond primary `pm`. */
|
||||
extraServices: string[];
|
||||
vendorId: string;
|
||||
company: string;
|
||||
tech: string;
|
||||
techPhone: string;
|
||||
apptStart: string;
|
||||
apptEnd: string;
|
||||
apptTime: string;
|
||||
status: WizardWOStatus | "";
|
||||
status: WizardWOStatus | "Canceled" | "";
|
||||
docStatus: CompletionDocStatus;
|
||||
pocName: string;
|
||||
pocPhone: string;
|
||||
|
|
@ -39,6 +44,11 @@ export interface WorkOrderTableRow {
|
|||
mediaCount: number;
|
||||
title: string;
|
||||
priority: string;
|
||||
primaryDispatchId: number | null;
|
||||
rowVersion: string;
|
||||
dispatchRowVersion: string;
|
||||
/** Board flag color (#RRGGBB). Null/empty = no flag. Distinct from dispatcher avatar `color`. */
|
||||
flagColor: string | null;
|
||||
}
|
||||
|
||||
export interface WorkOrderTableDraft {
|
||||
|
|
@ -52,6 +62,9 @@ export interface WorkOrderTableDraft {
|
|||
location: string;
|
||||
locationId: string | number;
|
||||
pm: string;
|
||||
serviceNotes: string;
|
||||
extraServices: string[];
|
||||
vendorId: string;
|
||||
company: string;
|
||||
tech: string;
|
||||
techPhone: string;
|
||||
|
|
@ -76,6 +89,9 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
|
|||
location: "",
|
||||
locationId: "",
|
||||
pm: "",
|
||||
serviceNotes: "",
|
||||
extraServices: [],
|
||||
vendorId: "",
|
||||
company: "",
|
||||
tech: "",
|
||||
techPhone: "",
|
||||
|
|
@ -89,11 +105,13 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
|
|||
targetWeek: "",
|
||||
};
|
||||
|
||||
export interface WorkOrderDayGroup {
|
||||
export type WorkOrderDayGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
date: string;
|
||||
items: WorkOrderTableRow[];
|
||||
}
|
||||
};
|
||||
|
||||
export type WorkOrderTablePatch = Partial<WorkOrderTableRow>;
|
||||
|
||||
export const UNSCHEDULED_DAY_KEY = "__unscheduled__" as const;
|
||||
|
|
|
|||
|
|
@ -16,9 +16,12 @@ export const ALL_WIZARD_STATUSES = [
|
|||
|
||||
export type WizardWOStatus = (typeof ALL_WIZARD_STATUSES)[number];
|
||||
|
||||
export type WizardSchedMode = "date" | "week" | "range";
|
||||
|
||||
export interface WorkOrderWizardDraft {
|
||||
locationId: string;
|
||||
locationName: string;
|
||||
siteCode: string;
|
||||
woNumber: string;
|
||||
type: WOType | "";
|
||||
assigneeId: string;
|
||||
|
|
@ -26,6 +29,10 @@ export interface WorkOrderWizardDraft {
|
|||
scheduledOn: string;
|
||||
dueDate: string;
|
||||
pm: string;
|
||||
/** Extra services beyond primary `pm` (Service & Notes). */
|
||||
extraServices: string[];
|
||||
/** Free-text note attached to the service (Service & Notes). */
|
||||
pmNote: string;
|
||||
vendorId: string;
|
||||
vendorName: string;
|
||||
techPhone: string;
|
||||
|
|
@ -37,11 +44,18 @@ export interface WorkOrderWizardDraft {
|
|||
pocPhone: string;
|
||||
pocNotes: string;
|
||||
scheduleWeekOnly: boolean;
|
||||
/** Monday ISO when week-only; preferred over deriving from scheduledOn (inline create). */
|
||||
targetWeek?: string;
|
||||
/** End date when Schedule On is a range (ISO; start = scheduledOn). */
|
||||
scheduleEnd?: string;
|
||||
/** Last Schedule On mode used. */
|
||||
schedMode?: WizardSchedMode;
|
||||
}
|
||||
|
||||
export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
||||
locationId: "",
|
||||
locationName: "",
|
||||
siteCode: "",
|
||||
woNumber: "",
|
||||
type: "",
|
||||
assigneeId: "",
|
||||
|
|
@ -49,6 +63,8 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
|||
scheduledOn: "",
|
||||
dueDate: "",
|
||||
pm: "",
|
||||
extraServices: [],
|
||||
pmNote: "",
|
||||
vendorId: "",
|
||||
vendorName: "",
|
||||
techPhone: "",
|
||||
|
|
@ -60,8 +76,20 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
|||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
};
|
||||
|
||||
/** WO number is optional; when provided must be digits only, 1–20 characters. */
|
||||
export function isWizardWoNumberValid(woNumber: string): boolean {
|
||||
const trimmed = woNumber.trim();
|
||||
if (!trimmed) {
|
||||
return true;
|
||||
}
|
||||
return /^\d{1,20}$/.test(trimmed);
|
||||
}
|
||||
|
||||
export const WIZARD_STEPS: { title: string; nav: string; subtitle: string }[] = [
|
||||
{
|
||||
title: "Type & schedule",
|
||||
|
|
@ -131,11 +159,11 @@ export const TIME_SLOTS: string[] = (() => {
|
|||
})();
|
||||
|
||||
export function canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean {
|
||||
return Boolean(draft.type);
|
||||
return Boolean(draft.type) && isWizardWoNumberValid(draft.woNumber);
|
||||
}
|
||||
|
||||
export function canCreateWorkOrderFromWizard(draft: WorkOrderWizardDraft): boolean {
|
||||
return Boolean(draft.locationId);
|
||||
return Boolean(draft.type && draft.siteCode) && isWizardWoNumberValid(draft.woNumber);
|
||||
}
|
||||
|
||||
export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): boolean {
|
||||
|
|
@ -143,7 +171,7 @@ export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): bo
|
|||
return canContinueWizardStep1(draft);
|
||||
}
|
||||
if (step === 2) {
|
||||
return Boolean(draft.locationId);
|
||||
return Boolean(draft.locationId && draft.siteCode);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,31 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import { shouldUseMockWoWizardCreate } from "@/domain/work-orders/mocks/is-wo-wizard-mock-enabled";
|
||||
import { buildMockWorkOrderDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
|
||||
import type { WorkOrderFormValues } from "@/domain/work-orders/types/work-order-detail";
|
||||
|
||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
async function createWorkOrderFromWizard(values: WorkOrderFormValues): Promise<WorkOrderDetail> {
|
||||
if (shouldUseMockWoWizardCreate(values.locationId)) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
return buildMockWorkOrderDetail(values);
|
||||
}
|
||||
|
||||
return workOrdersApi.create(values);
|
||||
}
|
||||
|
||||
export function useCreateWorkOrderFromWizard(
|
||||
onSuccess?: () => void,
|
||||
): UseMutationResult<WorkOrderDetail, Error, WorkOrderFormValues> {
|
||||
): UseMutationResult<WorkOrderDetail, Error, WorkOrderWizardDraft> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: createWorkOrderFromWizard,
|
||||
mutationFn: (draft: WorkOrderWizardDraft) => workOrdersApi.createFromBoard(draft),
|
||||
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||
|
||||
toast.success("Work Order created successfully!");
|
||||
|
||||
onSuccess?.();
|
||||
},
|
||||
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to create work order");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import { fetchWorkOrderEmployees } from "@/domain/work-orders/use-cases/fetch-work-order-employees";
|
||||
import type {
|
||||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
VendorDropdownItem,
|
||||
WorkOrderDetail,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import { requireQueryParam } from "@/lib/query/require-query-param";
|
||||
|
||||
|
|
@ -19,6 +20,30 @@ export function useWorkOrderDetail(
|
|||
queryKey: queryKeys.workOrders.detail(id ?? ""),
|
||||
queryFn: () => workOrdersApi.getById(requireQueryParam(id, "Work order id")),
|
||||
enabled: id !== undefined && id !== "",
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkOrderBoardDetail(
|
||||
id: string | number | undefined,
|
||||
enabled = true,
|
||||
): UseQueryResult<WorkOrderBoardDetail, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.workOrders.boardDetail(id ?? ""),
|
||||
queryFn: () => workOrdersApi.getBoardDetail(requireQueryParam(id, "Work order id")),
|
||||
enabled: enabled && id !== undefined && id !== "",
|
||||
// Do not keepPreviousData — placeholder detail from another WO leaks into the slide-over.
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkOrderMedia(
|
||||
id: string | number | undefined,
|
||||
enabled = true,
|
||||
): UseQueryResult<WorkOrderBoardDetail["media"], Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.workOrders.boardMedia(id ?? ""),
|
||||
queryFn: () => workOrdersApi.listMedia(requireQueryParam(id, "Work order id")),
|
||||
enabled: enabled && id !== undefined && id !== "",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -43,7 +68,7 @@ export function useWorkOrderVendors(
|
|||
siteZip?: string,
|
||||
): UseQueryResult<VendorDropdownItem[], Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.workOrders.vendors(trade, siteZip),
|
||||
queryKey: queryKeys.vendors.dropdown(trade, siteZip),
|
||||
queryFn: () => workOrdersApi.fetchVendorsDropdown(trade, siteZip),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
|
@ -64,5 +89,6 @@ export function useDispatchDetail(
|
|||
queryKey: queryKeys.workOrders.dispatch(dispatchId ?? ""),
|
||||
queryFn: () => workOrdersApi.getDispatchById(requireQueryParam(dispatchId, "Dispatch id")),
|
||||
enabled: dispatchId !== undefined && dispatchId !== "",
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/r
|
|||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import { BoardPatchConflictError } from "@/domain/work-orders/errors/board-patch-errors";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
|
||||
import type {
|
||||
DispatchComment,
|
||||
|
|
@ -13,15 +15,49 @@ import type {
|
|||
WorkOrderFormValues,
|
||||
WorkOrderVendorUpdatePayload,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import type {
|
||||
WorkOrderBoardCompletion,
|
||||
WorkOrderBoardMediaItem,
|
||||
} from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
function invalidateWorkOrders(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||
}
|
||||
|
||||
function invalidateBoardDetail(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
workOrderId: string | number,
|
||||
) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.workOrders.boardDetail(workOrderId),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.workOrders.boardMedia(workOrderId),
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
|
||||
}
|
||||
|
||||
interface AddWorkOrderCommentInput {
|
||||
text: string;
|
||||
commentType: "customer" | "internal";
|
||||
commentType?: "customer" | "internal";
|
||||
}
|
||||
|
||||
export interface UpdateWorkOrderCommentInput {
|
||||
commentId: string | number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface UploadWorkOrderMediaInput {
|
||||
file: File;
|
||||
category: UploadableMediaCategory;
|
||||
}
|
||||
|
||||
export interface UploadCompletionDocInput {
|
||||
file: File;
|
||||
workOrderVersion: string;
|
||||
signOffName?: string;
|
||||
signOffSignature?: string;
|
||||
}
|
||||
|
||||
export interface AddDispatchCommentInput {
|
||||
|
|
@ -76,7 +112,7 @@ export function useCreateWorkOrder(): UseMutationResult<
|
|||
|
||||
export function useUpdateWorkOrder(
|
||||
id: string | number,
|
||||
): UseMutationResult<WorkOrderDetail, Error, WorkOrderFormSchemaValues | WorkOrderFormValues> {
|
||||
): UseMutationResult<void, Error, WorkOrderFormSchemaValues | WorkOrderFormValues> {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
@ -121,10 +157,10 @@ export function useAddWorkOrderComment(
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ text, commentType }: AddWorkOrderCommentInput) =>
|
||||
workOrdersApi.addComment(workOrderId, text, commentType),
|
||||
mutationFn: ({ text }: AddWorkOrderCommentInput) =>
|
||||
workOrdersApi.createComment(workOrderId, text),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
|
||||
invalidateBoardDetail(queryClient, workOrderId);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to add comment");
|
||||
|
|
@ -132,6 +168,84 @@ export function useAddWorkOrderComment(
|
|||
});
|
||||
}
|
||||
|
||||
export function useUpdateWorkOrderComment(
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<WorkOrderComment, Error, UpdateWorkOrderCommentInput> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ commentId, text }: UpdateWorkOrderCommentInput) =>
|
||||
workOrdersApi.updateComment(workOrderId, commentId, text),
|
||||
onSuccess: () => {
|
||||
invalidateBoardDetail(queryClient, workOrderId);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update comment");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadWorkOrderMedia(
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<WorkOrderBoardMediaItem, Error, UploadWorkOrderMediaInput> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ file, category }: UploadWorkOrderMediaInput) =>
|
||||
workOrdersApi.uploadMedia(workOrderId, file, category),
|
||||
onSuccess: () => {
|
||||
invalidateBoardDetail(queryClient, workOrderId);
|
||||
invalidateWorkOrders(queryClient);
|
||||
toast.success("File uploaded");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to upload file");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWorkOrderMedia(
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<void, Error, string | number> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (mediaId: string | number) => workOrdersApi.deleteMedia(workOrderId, mediaId),
|
||||
onSuccess: () => {
|
||||
invalidateBoardDetail(queryClient, workOrderId);
|
||||
invalidateWorkOrders(queryClient);
|
||||
toast.success("File removed");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to remove file");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadCompletionDoc(
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<WorkOrderBoardCompletion, Error, UploadCompletionDocInput> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: UploadCompletionDocInput) =>
|
||||
workOrdersApi.uploadCompletionDoc(workOrderId, params),
|
||||
onSuccess: () => {
|
||||
invalidateBoardDetail(queryClient, workOrderId);
|
||||
invalidateWorkOrders(queryClient);
|
||||
toast.success("Completion document uploaded");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
if (error instanceof BoardPatchConflictError) {
|
||||
invalidateBoardDetail(queryClient, workOrderId);
|
||||
toast.error("Document is out of date. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
toast.error(error.message || "Failed to upload completion document");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useChangeWorkOrderStatus(
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<void, Error, string> {
|
||||
|
|
|
|||
|
|
@ -2,36 +2,29 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||
import { toast } from "react-toastify";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import {
|
||||
mapApiStatusToWizard,
|
||||
mapWizardStatusToApi,
|
||||
} from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
||||
BoardPatchConflictError,
|
||||
BoardPatchValidationError,
|
||||
} from "@/domain/work-orders/errors/board-patch-errors";
|
||||
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
||||
import {
|
||||
mapWorkOrderDetailToFormValues,
|
||||
mapWorkOrderTableRow,
|
||||
} from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
||||
buildBoardPatchPayload,
|
||||
expandTablePatchToBoardFieldOps,
|
||||
isLocalOnlyTablePatch,
|
||||
} from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
|
||||
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import type {
|
||||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export type WorkOrderTablePatch = Partial<WorkOrderTableRow>;
|
||||
|
||||
async function loadFormAndUpdate(
|
||||
id: string | number,
|
||||
patch: (
|
||||
values: ReturnType<typeof mapWorkOrderDetailToFormValues>,
|
||||
) => ReturnType<typeof mapWorkOrderDetailToFormValues>,
|
||||
) {
|
||||
const detail = await workOrdersApi.getById(id);
|
||||
const values = mapWorkOrderDetailToFormValues(detail);
|
||||
const next = patch(values);
|
||||
await workOrdersApi.update(id, next);
|
||||
return detail;
|
||||
}
|
||||
export type { WorkOrderTablePatch };
|
||||
|
||||
export function useWorkOrderTableMutations(
|
||||
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void,
|
||||
getRow?: (id: string | number) => WorkOrderTableRow | undefined,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
|
@ -40,68 +33,55 @@ export function useWorkOrderTableMutations(
|
|||
};
|
||||
|
||||
const patchMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
patch,
|
||||
useFullUpdate,
|
||||
}: {
|
||||
id: string | number;
|
||||
patch: WorkOrderTablePatch;
|
||||
useFullUpdate?: boolean;
|
||||
}) => {
|
||||
if (patch.status !== undefined && !useFullUpdate) {
|
||||
const apiStatus = mapWizardStatusToApi(patch.status as WizardWOStatus);
|
||||
await workOrdersApi.changeStatus(id, apiStatus);
|
||||
return patch;
|
||||
mutationFn: async ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) => {
|
||||
if (isLocalOnlyTablePatch(patch)) {
|
||||
return { kind: "local" as const, patch };
|
||||
}
|
||||
if (patch.dispatcherId !== undefined && !useFullUpdate) {
|
||||
await workOrdersApi.changeAssignment(id, patch.dispatcherId || "");
|
||||
return {
|
||||
...patch,
|
||||
dispatcherName: patch.dispatcherName ?? "",
|
||||
};
|
||||
|
||||
const ops = expandTablePatchToBoardFieldOps(patch);
|
||||
if (ops.length === 0) {
|
||||
throw new BoardPatchValidationError(
|
||||
"NoPersistableFields",
|
||||
"These changes cannot be saved on the board. Refresh and edit a supported field.",
|
||||
);
|
||||
}
|
||||
await loadFormAndUpdate(id, (values) => ({
|
||||
...values,
|
||||
workOrderNumber: patch.woNumber ?? values.workOrderNumber,
|
||||
internalWONumber: patch.woNumber ?? values.internalWONumber,
|
||||
locationId: patch.locationId != null ? String(patch.locationId) : values.locationId,
|
||||
dueDate: patch.dueDate ?? values.dueDate,
|
||||
scheduledDate: patch.scheduledOn ?? values.scheduledDate,
|
||||
assignedTo: patch.dispatcherName ?? values.assignedTo,
|
||||
problem: patch.pm ?? values.problem,
|
||||
source: patch.type ?? values.source,
|
||||
priority: values.priority,
|
||||
status:
|
||||
patch.status !== undefined
|
||||
? mapWizardStatusToApi(patch.status as WizardWOStatus)
|
||||
: values.status,
|
||||
description: [
|
||||
values.description,
|
||||
patch.pocNotes ? `POC notes: ${patch.pocNotes}` : "",
|
||||
patch.pocName || patch.pocPhone
|
||||
? `POC: ${[patch.pocName, patch.pocPhone].filter(Boolean).join(" · ")}`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
}));
|
||||
return patch;
|
||||
|
||||
const current = getRow?.(id);
|
||||
if (!current?.rowVersion) {
|
||||
throw new Error("Missing work order version for board patch. Refresh and retry.");
|
||||
}
|
||||
|
||||
let latest = current;
|
||||
for (const op of ops) {
|
||||
latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
|
||||
}
|
||||
|
||||
return { kind: "board" as const, row: latest };
|
||||
},
|
||||
onMutate: async ({ id, patch }) => {
|
||||
onPatch(id, patch);
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (result, { id }) => {
|
||||
if (result.kind === "board") {
|
||||
onPatch(id, result.row);
|
||||
}
|
||||
invalidate();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update work order");
|
||||
onError: (error: Error, { id }) => {
|
||||
if (error instanceof BoardPatchConflictError && error.currentState) {
|
||||
onPatch(id, error.currentState);
|
||||
toast.error(error.message || "Conflict — refreshed with latest data");
|
||||
} else if (error instanceof BoardPatchValidationError) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.error(error.message || "Failed to update work order");
|
||||
}
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const patchField = (id: string | number, patch: WorkOrderTablePatch, useFullUpdate = false) => {
|
||||
patchMutation.mutate({ id, patch, useFullUpdate });
|
||||
const patchField = (id: string | number, patch: WorkOrderTablePatch) => {
|
||||
patchMutation.mutate({ id, patch });
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
@ -124,6 +104,7 @@ export function tableRowFromDetail(detail: Awaited<ReturnType<typeof workOrdersA
|
|||
);
|
||||
}
|
||||
|
||||
/** Optimistic UI only — persistence auto-schedule is handled by the board PATCH API. */
|
||||
export function maybeAutoSchedulePatch(row: WorkOrderTableRow): WorkOrderTablePatch {
|
||||
const hasDate = !!row.scheduledOn;
|
||||
const hasDispatcher = !!row.dispatcherId;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ import type {
|
|||
WorkOrdersListResult,
|
||||
WorkOrderUser,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import type {
|
||||
WorkOrderBoardListResult,
|
||||
WorkOrderBoardQueryParams,
|
||||
WorkOrderBoardSearchQueryParams,
|
||||
} from "@/domain/work-orders/types/work-order-board";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export function useWorkOrdersList(
|
||||
|
|
@ -17,10 +22,34 @@ export function useWorkOrdersList(
|
|||
});
|
||||
}
|
||||
|
||||
export function useWorkOrdersBoard(
|
||||
params: WorkOrderBoardQueryParams,
|
||||
enabled = true,
|
||||
): UseQueryResult<WorkOrderBoardListResult, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.workOrders.board(params),
|
||||
queryFn: () => workOrdersApi.getBoard(params),
|
||||
enabled,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkOrdersBoardSearch(
|
||||
params: WorkOrderBoardSearchQueryParams,
|
||||
enabled = true,
|
||||
): UseQueryResult<WorkOrderBoardListResult, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.workOrders.boardSearch(params),
|
||||
queryFn: () => workOrdersApi.searchBoard(params),
|
||||
enabled,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkOrderUsers(): UseQueryResult<WorkOrderUser[], Error> {
|
||||
return useQuery({
|
||||
queryKey: [...queryKeys.workOrders.all, "users"] as const,
|
||||
queryFn: () => workOrdersApi.fetchUsers(),
|
||||
queryKey: queryKeys.workOrders.dispatchers(),
|
||||
queryFn: () => workOrdersApi.fetchDispatchers(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
169
src/domain/work-orders/utils/board-query-params.ts
Normal file
169
src/domain/work-orders/utils/board-query-params.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
|
||||
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
|
||||
import {
|
||||
BOARD_DISPATCHER_UNASSIGNED,
|
||||
DATE_RANGE_TO_PRESET,
|
||||
DOC_STATUS_API,
|
||||
LIFECYCLE_STATUS_API,
|
||||
WORK_ORDER_TYPE_API,
|
||||
type WorkOrderBoardQueryParams,
|
||||
type WorkOrderBoardSearchQueryParams,
|
||||
} from "@/domain/work-orders/types/work-order-board";
|
||||
import { mapWizardTypeToApi } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
|
||||
import type { CompletionDocFilter } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
export const BOARD_SEARCH_MAX_PAGE_SIZE = 100;
|
||||
|
||||
/** Backend `types=99` filters by `isPastDue`, not WorkOrderType.Other. */
|
||||
export const OVERDUE_TYPE_API = WORK_ORDER_TYPE_API.Other;
|
||||
|
||||
function toBoardDispatcherId(id: string): string {
|
||||
if (id === ASSIGNEE_FILTER_UNASSIGNED || id === BOARD_DISPATCHER_UNASSIGNED) {
|
||||
return BOARD_DISPATCHER_UNASSIGNED;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function mapDispatchers(ids: Iterable<string>): string[] | undefined {
|
||||
const list = [...ids].map(toBoardDispatcherId);
|
||||
return list.length > 0 ? list : undefined;
|
||||
}
|
||||
|
||||
function mapWoTypes(types: Iterable<WOType | string>): number[] | undefined {
|
||||
const list: number[] = [];
|
||||
for (const type of types) {
|
||||
if (type === "All") {
|
||||
continue;
|
||||
}
|
||||
if (type === "Overdue") {
|
||||
list.push(OVERDUE_TYPE_API);
|
||||
continue;
|
||||
}
|
||||
const api = mapWizardTypeToApi(type as WOType);
|
||||
if (api != null) {
|
||||
list.push(api);
|
||||
}
|
||||
}
|
||||
return list.length > 0 ? list : undefined;
|
||||
}
|
||||
|
||||
function mapStatuses(statuses: Iterable<WizardWOStatus | string>): number[] | undefined {
|
||||
const list: number[] = [];
|
||||
for (const status of statuses) {
|
||||
const value = LIFECYCLE_STATUS_API[status as WizardWOStatus];
|
||||
if (typeof value === "number") {
|
||||
list.push(value);
|
||||
} else if (status === "Canceled" || status === "Cancelled") {
|
||||
list.push(LIFECYCLE_STATUS_API.Canceled);
|
||||
}
|
||||
}
|
||||
return list.length > 0 ? list : undefined;
|
||||
}
|
||||
|
||||
function mapDocStatuses(docs: Iterable<CompletionDocFilter>): number[] | undefined {
|
||||
const list: number[] = [];
|
||||
for (const doc of docs) {
|
||||
if (doc === "Yes") list.push(DOC_STATUS_API.Yes);
|
||||
else if (doc === "No") list.push(DOC_STATUS_API.No);
|
||||
else if (doc === "NN") list.push(DOC_STATUS_API.NotNeeded);
|
||||
}
|
||||
return list.length > 0 ? list : undefined;
|
||||
}
|
||||
|
||||
/** Build GET /board query from weekly filter bar. */
|
||||
export function toBoardQueryParams(
|
||||
bar: WorkOrderBarFilters,
|
||||
search: string,
|
||||
): WorkOrderBoardQueryParams {
|
||||
const [, weekTo] = weekRange(bar.weekMonday);
|
||||
const types = bar.type !== "All" ? mapWoTypes([bar.type]) : undefined;
|
||||
|
||||
return {
|
||||
weekStart: bar.weekMonday,
|
||||
weekEnd: weekTo,
|
||||
dispatchers: mapDispatchers(bar.dispatcherIds),
|
||||
types,
|
||||
search: search.trim().length >= 2 ? search.trim() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build GET /board/search query from advanced filters (+ optional bar/search). */
|
||||
export function toBoardSearchQueryParams(input: {
|
||||
filters: WorkOrderAdvancedFilters;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortDir?: string;
|
||||
/** Optional company-name → vendorId resolution. */
|
||||
vendorIdsByCompanyName?: Map<string, number>;
|
||||
}): WorkOrderBoardSearchQueryParams {
|
||||
const { filters } = input;
|
||||
const datePreset = DATE_RANGE_TO_PRESET[filters.dateRange];
|
||||
const pageSize = Math.min(
|
||||
input.pageSize ?? BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||
BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||
);
|
||||
|
||||
const vendorIds: number[] = [];
|
||||
if (input.vendorIdsByCompanyName && filters.vendorTechs.size > 0) {
|
||||
for (const name of filters.vendorTechs) {
|
||||
const id = input.vendorIdsByCompanyName.get(name);
|
||||
if (typeof id === "number") {
|
||||
vendorIds.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const params: WorkOrderBoardSearchQueryParams = {
|
||||
page: input.page ?? 0,
|
||||
pageSize,
|
||||
search:
|
||||
input.search?.trim() && input.search.trim().length >= 2 ? input.search.trim() : undefined,
|
||||
datePreset,
|
||||
sites: filters.sites.size > 0 ? [...filters.sites] : undefined,
|
||||
types: mapWoTypes(filters.types),
|
||||
dispatchers: mapDispatchers(filters.dispatchers),
|
||||
statuses: mapStatuses(filters.statuses),
|
||||
pmTypes: filters.pmTypes.size > 0 ? [...filters.pmTypes] : undefined,
|
||||
vendorIds: vendorIds.length > 0 ? vendorIds : undefined,
|
||||
docStatuses: mapDocStatuses(filters.docs),
|
||||
sortBy: input.sortBy || "scheduledDate",
|
||||
sortDir: input.sortDir === "desc" ? "desc" : "asc",
|
||||
};
|
||||
|
||||
if (datePreset === "Custom") {
|
||||
params.dateFrom = filters.customFrom || undefined;
|
||||
params.dateTo = filters.customTo || undefined;
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/** ky/URLSearchParams-friendly object; arrays become repeated keys. */
|
||||
export function buildBoardSearchParamsRecord(
|
||||
params: WorkOrderBoardQueryParams | WorkOrderBoardSearchQueryParams,
|
||||
): Record<string, string | number | boolean | (string | number)[]> {
|
||||
const out: Record<string, string | number | boolean | (string | number)[]> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) continue;
|
||||
out[key] = value;
|
||||
continue;
|
||||
}
|
||||
out[key] = value as string | number | boolean;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function woTypeFilterIsOverdue(type: string): boolean {
|
||||
return type === "Overdue";
|
||||
}
|
||||
156
src/domain/work-orders/utils/board-row-order-storage.ts
Normal file
156
src/domain/work-orders/utils/board-row-order-storage.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
export type BoardRowOrderByDay = Record<string, string[]>;
|
||||
|
||||
const STORAGE_PREFIX = "shoc:wo-board-row-order:v1:";
|
||||
|
||||
export function boardRowOrderStorageKey(weekMonday: string): string {
|
||||
return `${STORAGE_PREFIX}${weekMonday}`;
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function isBoardRowOrderByDay(value: unknown): value is BoardRowOrderByDay {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(value).every(isStringArray);
|
||||
}
|
||||
|
||||
export function loadBoardRowOrder(weekMonday: string): BoardRowOrderByDay {
|
||||
if (typeof window === "undefined" || !weekMonday) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(boardRowOrderStorageKey(weekMonday));
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return isBoardRowOrderByDay(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function saveBoardRowOrder(weekMonday: string, orderByDay: BoardRowOrderByDay): void {
|
||||
if (typeof window === "undefined" || !weekMonday) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(boardRowOrderStorageKey(weekMonday), JSON.stringify(orderByDay));
|
||||
} catch {
|
||||
// Quota exceeded or storage blocked — keep in-memory order only.
|
||||
}
|
||||
}
|
||||
|
||||
/** Reorder `items` so known ids follow `orderedIds`; unknown ids stay at the end (stable). */
|
||||
export function applyDayRowOrder<T extends { id: string | number }>(
|
||||
items: T[],
|
||||
orderedIds: string[] | undefined,
|
||||
): T[] {
|
||||
if (!orderedIds?.length || items.length <= 1) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const orderMap = new Map(orderedIds.map((id, idx) => [id, idx]));
|
||||
return [...items].sort((a, b) => {
|
||||
const ai = orderMap.get(String(a.id));
|
||||
const bi = orderMap.get(String(b.id));
|
||||
if (ai === undefined && bi === undefined) {
|
||||
return 0;
|
||||
}
|
||||
if (ai === undefined) {
|
||||
return 1;
|
||||
}
|
||||
if (bi === undefined) {
|
||||
return -1;
|
||||
}
|
||||
return ai - bi;
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply saved per-day orders across a flat row list (preserves relative order across days). */
|
||||
export function applyRowOrderByDay<T extends { id: string | number; dayGroup: string }>(
|
||||
items: T[],
|
||||
orderByDay: BoardRowOrderByDay,
|
||||
): T[] {
|
||||
if (!items.length || !Object.keys(orderByDay).length) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const byDay = new Map<string, T[]>();
|
||||
for (const item of items) {
|
||||
const group = byDay.get(item.dayGroup);
|
||||
if (group) {
|
||||
group.push(item);
|
||||
} else {
|
||||
byDay.set(item.dayGroup, [item]);
|
||||
}
|
||||
}
|
||||
|
||||
const orderedByDay = new Map<string, T[]>();
|
||||
for (const [dayGroup, dayItems] of byDay) {
|
||||
orderedByDay.set(dayGroup, applyDayRowOrder(dayItems, orderByDay[dayGroup]));
|
||||
}
|
||||
|
||||
const nextIndex = new Map<string, number>();
|
||||
return items.map((item) => {
|
||||
const dayItems = orderedByDay.get(item.dayGroup)!;
|
||||
const idx = nextIndex.get(item.dayGroup) ?? 0;
|
||||
nextIndex.set(item.dayGroup, idx + 1);
|
||||
return dayItems[idx]!;
|
||||
});
|
||||
}
|
||||
|
||||
export function reorderIdsInList(ids: string[], dragId: string, overId: string): string[] | null {
|
||||
const dragIdx = ids.indexOf(dragId);
|
||||
const overIdx = ids.indexOf(overId);
|
||||
if (dragIdx < 0 || overIdx < 0 || dragIdx === overIdx) {
|
||||
return null;
|
||||
}
|
||||
const next = [...ids];
|
||||
next.splice(dragIdx, 1);
|
||||
next.splice(overIdx, 0, dragId);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Remove an id from every day bucket (used when a row changes dayGroup). */
|
||||
export function removeIdFromRowOrder(
|
||||
orderByDay: BoardRowOrderByDay,
|
||||
id: string,
|
||||
): BoardRowOrderByDay {
|
||||
let changed = false;
|
||||
const next: BoardRowOrderByDay = {};
|
||||
for (const [dayGroup, ids] of Object.entries(orderByDay)) {
|
||||
if (!ids.includes(id)) {
|
||||
next[dayGroup] = ids;
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
const filtered = ids.filter((item) => item !== id);
|
||||
if (filtered.length) {
|
||||
next[dayGroup] = filtered;
|
||||
}
|
||||
}
|
||||
return changed ? next : orderByDay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a visible day's new order into stored order: visible ids take the new sequence,
|
||||
* ids that were stored but not currently visible stay after the visible block (preserving
|
||||
* their prior relative order).
|
||||
*/
|
||||
export function mergeDayOrder(
|
||||
previousDayIds: string[] | undefined,
|
||||
visibleOrderedIds: string[],
|
||||
): string[] {
|
||||
if (!previousDayIds?.length) {
|
||||
return visibleOrderedIds;
|
||||
}
|
||||
const visibleSet = new Set(visibleOrderedIds);
|
||||
const hidden = previousDayIds.filter((id) => !visibleSet.has(id));
|
||||
return [...visibleOrderedIds, ...hidden];
|
||||
}
|
||||
11
src/domain/work-orders/utils/comment-ownership.ts
Normal file
11
src/domain/work-orders/utils/comment-ownership.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { WorkOrderComment } from "@/domain/work-orders/types/work-order-detail";
|
||||
|
||||
export function isOwnWorkOrderComment(
|
||||
comment: WorkOrderComment,
|
||||
currentUserId: string | number | null | undefined,
|
||||
): boolean {
|
||||
if (currentUserId == null || currentUserId === "") return false;
|
||||
const authorId = comment.authorId;
|
||||
if (authorId == null || authorId === "") return false;
|
||||
return String(authorId) === String(currentUserId);
|
||||
}
|
||||
35
src/domain/work-orders/utils/dispatcher-avatar-color.ts
Normal file
35
src/domain/work-orders/utils/dispatcher-avatar-color.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
"#F59E0B",
|
||||
"#0D9488",
|
||||
"#6366F1",
|
||||
"#F43F5E",
|
||||
"#7C3AED",
|
||||
"#0EA5E9",
|
||||
"#16A34A",
|
||||
"#EA580C",
|
||||
];
|
||||
|
||||
function paletteColorFromKey(key: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < key.length; i += 1) {
|
||||
hash = key.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
|
||||
}
|
||||
|
||||
export function getDispatcherAvatarColor(user: WorkOrderUser): string {
|
||||
return paletteColorFromKey(String(user.id || user.name));
|
||||
}
|
||||
|
||||
export function getAvatarColorFromName(name: string): string {
|
||||
return paletteColorFromKey(name.trim() || "?");
|
||||
}
|
||||
|
||||
export function getInitialsFromName(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "?";
|
||||
if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
|
||||
return `${parts[0]![0] ?? ""}${parts[1]![0] ?? ""}`.toUpperCase();
|
||||
}
|
||||
178
src/domain/work-orders/utils/format-board-audit-action.ts
Normal file
178
src/domain/work-orders/utils/format-board-audit-action.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order-detail";
|
||||
|
||||
type AuditActionSource = Pick<
|
||||
WorkOrderAuditEntry,
|
||||
"action" | "fieldName" | "oldValue" | "newValue"
|
||||
>;
|
||||
|
||||
function looksLikeHumanSentence(action: string): boolean {
|
||||
return /\s/.test(action);
|
||||
}
|
||||
|
||||
function humanizeFieldLabel(fieldName: string): string {
|
||||
const spaced = fieldName
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/[_-]+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return spaced || "field";
|
||||
}
|
||||
|
||||
function normalizeKey(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_-]+/g, "");
|
||||
}
|
||||
|
||||
/** JSON array strings → `"a", "b", "c"` instead of `["a","b","c"]`. */
|
||||
function formatJsonArrayDisplayValue(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null;
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
|
||||
const items = parsed
|
||||
.map((item) => String(item ?? "").trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => `"${item}"`);
|
||||
|
||||
return items.join(", ");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type FormattedAuditValue = {
|
||||
text: string;
|
||||
/** Per-item quotes already applied; omit outer quotes in sentence templates. */
|
||||
isQuotedList: boolean;
|
||||
};
|
||||
|
||||
function wrapAuditValue(value: FormattedAuditValue): string {
|
||||
if (!value.text) return value.text;
|
||||
return value.isQuotedList ? value.text : `"${value.text}"`;
|
||||
}
|
||||
|
||||
/** Parse ISO / date-only / pipe-separated API values → unique mm/dd/yyyy. */
|
||||
function formatAuditDisplayValue(raw: string): FormattedAuditValue {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { text: trimmed, isQuotedList: false };
|
||||
|
||||
const jsonArray = formatJsonArrayDisplayValue(trimmed);
|
||||
if (jsonArray !== null) return { text: jsonArray, isQuotedList: true };
|
||||
|
||||
const parts = trimmed
|
||||
.split("|")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const dates: string[] = [];
|
||||
for (const part of parts) {
|
||||
const isoMatch = part.match(/^(\d{4})-(\d{2})-(\d{2})(?:T|$)/);
|
||||
if (isoMatch) {
|
||||
const [, year, month, day] = isoMatch;
|
||||
dates.push(`${month}/${day}/${year}`);
|
||||
continue;
|
||||
}
|
||||
const date = new Date(part);
|
||||
if (!Number.isNaN(date.getTime()) && /\d{4}/.test(part)) {
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const year = String(date.getFullYear());
|
||||
dates.push(`${month}/${day}/${year}`);
|
||||
continue;
|
||||
}
|
||||
return { text: trimmed, isQuotedList: false };
|
||||
}
|
||||
|
||||
const unique = [...new Set(dates)];
|
||||
if (unique.length === 0) return { text: trimmed, isQuotedList: false };
|
||||
if (unique.length === 1) return { text: unique[0]!, isQuotedList: false };
|
||||
return { text: unique.join(" – "), isQuotedList: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a design-style audit sentence (the part after `Name · `),
|
||||
* e.g. `Changed status to "Incomplete"`, from board API fields.
|
||||
*/
|
||||
export function formatBoardAuditAction(entry: AuditActionSource): string {
|
||||
const action = entry.action?.trim() ?? "";
|
||||
const fieldName = entry.fieldName?.trim() ?? "";
|
||||
const oldValue = formatAuditDisplayValue(entry.oldValue?.trim() ?? "");
|
||||
const newValue = formatAuditDisplayValue(entry.newValue?.trim() ?? "");
|
||||
|
||||
if (action && looksLikeHumanSentence(action)) {
|
||||
return action;
|
||||
}
|
||||
|
||||
const fieldKey = normalizeKey(fieldName);
|
||||
const actionKey = normalizeKey(action);
|
||||
|
||||
if (
|
||||
fieldKey.includes("status") ||
|
||||
fieldKey === "lifecyclestatus" ||
|
||||
actionKey.includes("status")
|
||||
) {
|
||||
if (newValue.text) return `Changed status to ${wrapAuditValue(newValue)}`;
|
||||
}
|
||||
|
||||
if (fieldKey.includes("appt") || fieldKey.includes("appointment") || actionKey.includes("appt")) {
|
||||
if (oldValue.text && newValue.text) {
|
||||
return `Changed appt time from ${wrapAuditValue(oldValue)} to ${wrapAuditValue(newValue)}`;
|
||||
}
|
||||
if (newValue.text) return `Changed appt time to ${wrapAuditValue(newValue)}`;
|
||||
}
|
||||
|
||||
if (
|
||||
fieldKey.includes("scheduled") ||
|
||||
fieldKey === "schedule" ||
|
||||
fieldKey.includes("scheduledate") ||
|
||||
fieldKey.includes("duedate") ||
|
||||
actionKey === "rescheduled" ||
|
||||
actionKey.includes("reschedule")
|
||||
) {
|
||||
if (newValue.text) return `Changed schedule to ${wrapAuditValue(newValue)}`;
|
||||
return "Rescheduled work order";
|
||||
}
|
||||
|
||||
if (
|
||||
fieldKey.includes("carriedover") ||
|
||||
actionKey === "weekrolled" ||
|
||||
actionKey.includes("carryover")
|
||||
) {
|
||||
const increment = Number(entry.newValue?.trim() ?? "");
|
||||
if (Number.isFinite(increment) && increment > 0) {
|
||||
return `Carried over to the next week (increment #${increment})`;
|
||||
}
|
||||
return "Carried over to the next week";
|
||||
}
|
||||
|
||||
if (
|
||||
fieldKey.includes("pastdue") ||
|
||||
actionKey.includes("pastdue") ||
|
||||
actionKey === "markedpastdue"
|
||||
) {
|
||||
return "WO marked as Past Due. Scheduled date has passed without completion.";
|
||||
}
|
||||
|
||||
if (fieldKey === "created" || actionKey === "workordercreated" || actionKey === "created") {
|
||||
return "Work order created in the system.";
|
||||
}
|
||||
|
||||
if (!action && !fieldName && !oldValue.text && !newValue.text) {
|
||||
return "Work order created in the system.";
|
||||
}
|
||||
|
||||
const label = humanizeFieldLabel(fieldName || action || "field");
|
||||
if (oldValue.text && newValue.text) {
|
||||
return `Changed ${label} from ${wrapAuditValue(oldValue)} to ${wrapAuditValue(newValue)}`;
|
||||
}
|
||||
if (newValue.text) {
|
||||
return `Changed ${label} to ${wrapAuditValue(newValue)}`;
|
||||
}
|
||||
if (action) return action;
|
||||
return "Updated work order";
|
||||
}
|
||||
16
src/domain/work-orders/utils/service-notes-ui.ts
Normal file
16
src/domain/work-orders/utils/service-notes-ui.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/** Design: cell shows Service & Notes layout when note or extras exist. */
|
||||
export function hasServiceNotesDetails(
|
||||
serviceNotes?: string | null,
|
||||
extraServices?: string[] | null,
|
||||
): boolean {
|
||||
return Boolean(serviceNotes?.trim()) || (extraServices?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** Truncate note for the board cell subtitle (~30 chars, design). */
|
||||
export function truncateServiceNotesPreview(note: string, max = 30): string {
|
||||
const trimmed = note.trim();
|
||||
if (trimmed.length <= max) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed.slice(0, max)}…`;
|
||||
}
|
||||
9
src/domain/work-orders/utils/should-notify-cross-week.ts
Normal file
9
src/domain/work-orders/utils/should-notify-cross-week.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
/** True when the new scheduled date falls outside the week currently being viewed. */
|
||||
export function shouldNotifyCrossWeek(dateIso: string, activeWeekMonday: string): boolean {
|
||||
if (!dateIso || !activeWeekMonday) {
|
||||
return false;
|
||||
}
|
||||
return mondayOf(dateIso) !== activeWeekMonday;
|
||||
}
|
||||
73
src/domain/work-orders/utils/slide-over-draft.ts
Normal file
73
src/domain/work-orders/utils/slide-over-draft.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type {
|
||||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
/**
|
||||
* Fields compared for dirty state and included in Save patch.
|
||||
* Only keys that map to board PATCH (or required companions like dispatcherName / vendor labels).
|
||||
* POC, locationId, and tech phone are not board-editable — exclude so Save cannot no-op.
|
||||
*/
|
||||
export const SLIDE_OVER_EDIT_KEYS = [
|
||||
"woNumber",
|
||||
"site",
|
||||
"type",
|
||||
"dispatcherId",
|
||||
"dispatcherName",
|
||||
"scheduledOn",
|
||||
"targetWeek",
|
||||
"scheduleWeekOnly",
|
||||
"dueDate",
|
||||
"apptTime",
|
||||
"pm",
|
||||
"serviceNotes",
|
||||
"extraServices",
|
||||
"vendorId",
|
||||
"company",
|
||||
"tech",
|
||||
"status",
|
||||
] as const satisfies ReadonlyArray<keyof WorkOrderTableRow>;
|
||||
|
||||
type EditKey = (typeof SLIDE_OVER_EDIT_KEYS)[number];
|
||||
|
||||
function sameExtraServices(a: string[] | undefined, b: string[] | undefined): boolean {
|
||||
const left = a ?? [];
|
||||
const right = b ?? [];
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
export function isSlideOverDraftDirty(
|
||||
draft: WorkOrderTableRow,
|
||||
baseline: WorkOrderTableRow,
|
||||
): boolean {
|
||||
for (const key of SLIDE_OVER_EDIT_KEYS) {
|
||||
if (key === "extraServices") {
|
||||
if (!sameExtraServices(draft.extraServices, baseline.extraServices)) return true;
|
||||
continue;
|
||||
}
|
||||
if (draft[key] !== baseline[key]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildSlideOverPatch(
|
||||
draft: WorkOrderTableRow,
|
||||
baseline: WorkOrderTableRow,
|
||||
): WorkOrderTablePatch {
|
||||
const patch: WorkOrderTablePatch = {};
|
||||
|
||||
for (const key of SLIDE_OVER_EDIT_KEYS) {
|
||||
if (key === "extraServices") {
|
||||
if (!sameExtraServices(draft.extraServices, baseline.extraServices)) {
|
||||
patch.extraServices = draft.extraServices;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (draft[key] !== baseline[key]) {
|
||||
(patch as Record<EditKey, WorkOrderTableRow[EditKey]>)[key] = draft[key];
|
||||
}
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
|
@ -12,17 +12,22 @@ import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
|
|||
const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]);
|
||||
|
||||
export function computeIsPastDue(row: WorkOrderTableRow): boolean {
|
||||
if (!row.dueDate || TERMINAL_STATUSES.has(row.status)) {
|
||||
if (TERMINAL_STATUSES.has(row.status)) {
|
||||
return false;
|
||||
}
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return row.dueDate < today;
|
||||
const date = row.scheduledOn || row.dueDate;
|
||||
if (!date) {
|
||||
return false;
|
||||
}
|
||||
return date < today;
|
||||
}
|
||||
|
||||
export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow {
|
||||
return {
|
||||
...row,
|
||||
isPastDue: computeIsPastDue(row),
|
||||
// Prefer API board flag when true; otherwise recompute from scheduledOn/dueDate.
|
||||
isPastDue: row.isPastDue || computeIsPastDue(row),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -37,8 +42,12 @@ export function matchesAdvancedFilters(
|
|||
if (filters.sites.size && !filters.sites.has(row.site)) {
|
||||
return false;
|
||||
}
|
||||
if (filters.types.size && !filters.types.has(row.type as never)) {
|
||||
return false;
|
||||
if (filters.types.size) {
|
||||
const overdueMatch = filters.types.has("Overdue") && row.isPastDue;
|
||||
const typeMatch = filters.types.has(row.type as never);
|
||||
if (!overdueMatch && !typeMatch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (filters.dispatchers.size) {
|
||||
const unassignedMatch =
|
||||
|
|
@ -54,7 +63,11 @@ export function matchesAdvancedFilters(
|
|||
if (filters.pmTypes.size && !filters.pmTypes.has(row.pm)) {
|
||||
return false;
|
||||
}
|
||||
if (filters.vendorTechs.size && !filters.vendorTechs.has(row.tech)) {
|
||||
if (
|
||||
filters.vendorTechs.size &&
|
||||
!filters.vendorTechs.has(row.tech) &&
|
||||
!filters.vendorTechs.has(row.company)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (filters.docs.size && !filters.docs.has(row.docStatus as never)) {
|
||||
|
|
@ -99,7 +112,11 @@ export function filterByBarAndSearch(
|
|||
return false;
|
||||
}
|
||||
|
||||
if (bar.type !== "All" && row.type !== bar.type) {
|
||||
if (bar.type === "Overdue") {
|
||||
if (!row.isPastDue) {
|
||||
return false;
|
||||
}
|
||||
} else if (bar.type !== "All" && row.type !== bar.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
22
src/domain/work-orders/utils/vendor-dropdown-label.ts
Normal file
22
src/domain/work-orders/utils/vendor-dropdown-label.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order-common";
|
||||
|
||||
export function formatVendorDistance(miles: number | null | undefined): string {
|
||||
if (miles == null || Number.isNaN(miles)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const rounded = Number.isInteger(miles) ? String(miles) : miles.toFixed(1);
|
||||
return `${rounded} mi`;
|
||||
}
|
||||
|
||||
/** Dropdown label: `contactName · companyName` (matches technician select UI). */
|
||||
export function formatVendorTechnicianLabel(vendor: VendorDropdownItem): string {
|
||||
const contact = vendor.contactName.trim();
|
||||
const company = vendor.companyName.trim();
|
||||
|
||||
if (contact && company && contact !== company) {
|
||||
return `${contact} · ${company}`;
|
||||
}
|
||||
|
||||
return contact || company;
|
||||
}
|
||||
|
|
@ -72,6 +72,27 @@ export function fmtWeekRange(startIso: string): string {
|
|||
return weekRangeLabel(startIso);
|
||||
}
|
||||
|
||||
/** Short date for ranges: "Jun 8". */
|
||||
export function fmtShortDate(iso: string): string {
|
||||
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
|
||||
return "—";
|
||||
}
|
||||
const [, m, d] = iso.split("-").map(Number);
|
||||
return `${MONTH_NAMES[m - 1].slice(0, 3)} ${d}`;
|
||||
}
|
||||
|
||||
/** "Jun 8 – Jun 12" for a Schedule On range. */
|
||||
export function fmtDateRange(start: string, end: string): string {
|
||||
return `${fmtShortDate(start)} – ${fmtShortDate(end)}`;
|
||||
}
|
||||
|
||||
/** Inclusive day count between two ISO dates (order-independent). */
|
||||
export function rangeDayCount(a: string, b: string): number {
|
||||
const d1 = new Date(`${a}T12:00:00`).getTime();
|
||||
const d2 = new Date(`${b}T12:00:00`).getTime();
|
||||
return Math.abs(Math.round((d2 - d1) / 86400000)) + 1;
|
||||
}
|
||||
|
||||
export function fmt12(t: string): string {
|
||||
const [h, m] = t.split(":").map(Number);
|
||||
const ampm = h < 12 ? "AM" : "PM";
|
||||
|
|
|
|||
27
src/domain/work-orders/utils/work-order-media-limits.ts
Normal file
27
src/domain/work-orders/utils/work-order-media-limits.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/** Backend RequestSizeLimit for media and completion-doc uploads. */
|
||||
export const MAX_WORK_ORDER_MEDIA_BYTES = 30 * 1024 * 1024;
|
||||
|
||||
export function isWorkOrderFileTooLarge(
|
||||
file: File,
|
||||
maxBytes = MAX_WORK_ORDER_MEDIA_BYTES,
|
||||
): boolean {
|
||||
return file.size > maxBytes;
|
||||
}
|
||||
|
||||
export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string {
|
||||
return `${Math.round(maxBytes / (1024 * 1024))} MB`;
|
||||
}
|
||||
|
||||
/** Backend IsReadOnly: Canceled or Completed. */
|
||||
export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean {
|
||||
return status === "Canceled" || status === "Completed";
|
||||
}
|
||||
|
||||
export function canDeleteWorkOrderMedia(media: {
|
||||
id: string | number;
|
||||
isLegacy?: boolean;
|
||||
}): boolean {
|
||||
if (media.isLegacy) return false;
|
||||
const numericId = typeof media.id === "number" ? media.id : Number(media.id);
|
||||
return Number.isFinite(numericId) && numericId > 0;
|
||||
}
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list";
|
||||
import type {
|
||||
WorkOrderBoardQueryParams,
|
||||
WorkOrderBoardSearchQueryParams,
|
||||
} from "@/domain/work-orders/types/work-order-board";
|
||||
|
||||
export const queryKeys = {
|
||||
auth: {
|
||||
|
|
@ -9,13 +13,18 @@ export const queryKeys = {
|
|||
all: ["workOrders"] as const,
|
||||
list: (params?: WorkOrdersListParams) =>
|
||||
[...queryKeys.workOrders.all, "list", params ?? {}] as const,
|
||||
board: (params?: WorkOrderBoardQueryParams) =>
|
||||
[...queryKeys.workOrders.all, "board", params ?? {}] as const,
|
||||
boardSearch: (params?: WorkOrderBoardSearchQueryParams) =>
|
||||
[...queryKeys.workOrders.all, "boardSearch", params ?? {}] as const,
|
||||
byLocation: (locationId: string | number, search = "") =>
|
||||
[...queryKeys.workOrders.all, "byLocation", locationId, search] as const,
|
||||
detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const,
|
||||
boardDetail: (id: string | number) => [...queryKeys.workOrders.all, "boardDetail", id] as const,
|
||||
boardMedia: (id: string | number) => [...queryKeys.workOrders.all, "boardMedia", id] as const,
|
||||
dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const,
|
||||
vendors: (trade?: string, siteZip?: string) =>
|
||||
[...queryKeys.workOrders.all, "vendors", trade ?? "", siteZip ?? ""] as const,
|
||||
taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const,
|
||||
dispatchers: () => [...queryKeys.workOrders.all, "dispatchers"] as const,
|
||||
},
|
||||
pmSchedules: {
|
||||
all: ["pmSchedules"] as const,
|
||||
|
|
@ -50,6 +59,7 @@ export const queryKeys = {
|
|||
[...queryKeys.locations.all, "list", params ?? {}] as const,
|
||||
detail: (id: string | number) => [...queryKeys.locations.all, "detail", id] as const,
|
||||
dropdown: () => [...queryKeys.locations.all, "dropdown"] as const,
|
||||
sites: (search = "") => [...queryKeys.locations.all, "sites", search] as const,
|
||||
},
|
||||
vendors: {
|
||||
all: ["vendors"] as const,
|
||||
|
|
@ -57,6 +67,8 @@ export const queryKeys = {
|
|||
[...queryKeys.vendors.all, "list", params ?? {}] as const,
|
||||
detail: (id: string | number) => [...queryKeys.vendors.all, "detail", id] as const,
|
||||
facets: (isActive?: boolean) => [...queryKeys.vendors.all, "facets", isActive ?? null] as const,
|
||||
dropdown: (trade?: string, siteZip?: string) =>
|
||||
[...queryKeys.vendors.all, "dropdown", trade ?? "", siteZip ?? ""] as const,
|
||||
portalToken: (vendorId: string | number) =>
|
||||
[...queryKeys.vendors.all, "portalToken", vendorId] as const,
|
||||
deactivationImpact: (vendorId: string | number) =>
|
||||
|
|
|
|||
153
src/test/domain/locations/api/locations-sites-api.test.ts
Normal file
153
src/test/domain/locations/api/locations-sites-api.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
|
||||
const apiGet = vi.fn();
|
||||
|
||||
vi.mock("@/api/api", () => ({
|
||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||
apiPost: vi.fn(),
|
||||
apiPut: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
import { locationsApi } from "@/domain/locations/api/locations-api";
|
||||
import {
|
||||
formatLocationAddressPreview,
|
||||
formatLocationSiteLabel,
|
||||
formatSitePlace,
|
||||
formatSiteTableLabel,
|
||||
mapLocation,
|
||||
mapLocationSite,
|
||||
} from "@/domain/locations/mappers/location-mapper";
|
||||
|
||||
describe("mapLocationSite", () => {
|
||||
it("maps code/city/state site payload", () => {
|
||||
expect(mapLocationSite({ id: 1, code: "BK5", city: "Dallas", state: "TX" })).toEqual({
|
||||
id: 1,
|
||||
code: "BK5",
|
||||
city: "Dallas",
|
||||
state: "TX",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapLocation", () => {
|
||||
it("maps detail payload from locations/{id}", () => {
|
||||
expect(
|
||||
mapLocation({
|
||||
id: 2,
|
||||
name: "BK5",
|
||||
title: "...",
|
||||
address: "3811 Distribution Dr",
|
||||
address1: "3811 Distribution Dr",
|
||||
address2: null,
|
||||
city: "Dallas",
|
||||
state: "TX",
|
||||
zipCode: "70068",
|
||||
phone: "(421) 433-0032",
|
||||
contact: null,
|
||||
contactEmail: "site.bk5@seahavenind.com",
|
||||
status: "Active",
|
||||
}),
|
||||
).toMatchObject({
|
||||
id: 2,
|
||||
name: "BK5",
|
||||
address: "3811 Distribution Dr",
|
||||
city: "Dallas",
|
||||
state: "TX",
|
||||
zipCode: "70068",
|
||||
phone: "(421) 433-0032",
|
||||
contactEmail: "site.bk5@seahavenind.com",
|
||||
status: "Active",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to address1/address2 when address is empty", () => {
|
||||
expect(
|
||||
mapLocation({
|
||||
id: 3,
|
||||
address1: "100 Main St",
|
||||
address2: "Suite 2",
|
||||
city: "Dallas",
|
||||
state: "TX",
|
||||
zipCode: "75001",
|
||||
}),
|
||||
).toMatchObject({
|
||||
address: "100 Main St, Suite 2",
|
||||
city: "Dallas",
|
||||
state: "TX",
|
||||
zipCode: "75001",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatLocationSiteLabel", () => {
|
||||
it("formats site label for dropdown", () => {
|
||||
expect(formatLocationSiteLabel({ code: "BK5", city: "Dallas", state: "TX" })).toBe(
|
||||
"BK5 · Dallas, TX",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatLocationAddressPreview", () => {
|
||||
it("formats US address without comma before zip", () => {
|
||||
expect(
|
||||
formatLocationAddressPreview({
|
||||
address: "3811 Distribution Dr",
|
||||
city: "Dallas",
|
||||
state: "TX",
|
||||
zipCode: "70068",
|
||||
}),
|
||||
).toBe("3811 Distribution Dr, Dallas, TX 70068");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSiteTableLabel", () => {
|
||||
it("formats table cell as code . city, ST", () => {
|
||||
expect(formatSiteTableLabel({ code: "BK5", city: "Dallas", state: "TX" })).toBe(
|
||||
"BK5 . Dallas, TX",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to location when city/state missing", () => {
|
||||
expect(formatSiteTableLabel({ code: "BK5", fallbackLocation: "Lobby" })).toBe("BK5 . Lobby");
|
||||
});
|
||||
|
||||
it("returns only code when place is empty", () => {
|
||||
expect(formatSiteTableLabel({ code: "BK5" })).toBe("BK5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSitePlace", () => {
|
||||
it("joins city and state", () => {
|
||||
expect(formatSitePlace("Dallas", "TX")).toBe("Dallas, TX");
|
||||
});
|
||||
|
||||
it("uses fallback when city/state empty", () => {
|
||||
expect(formatSitePlace("", "", "Lobby")).toBe("Lobby");
|
||||
});
|
||||
});
|
||||
|
||||
describe("locationsApi.getSites", () => {
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
apiGet.mockResolvedValue([{ id: 1, code: "BK5", city: "Dallas", state: "TX" }]);
|
||||
});
|
||||
|
||||
it("calls locations/sites without search", async () => {
|
||||
const sites = await locationsApi.getSites();
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith(API_PATHS.location.sites, {
|
||||
searchParams: undefined,
|
||||
});
|
||||
expect(sites).toEqual([{ id: 1, code: "BK5", city: "Dallas", state: "TX" }]);
|
||||
});
|
||||
|
||||
it("passes search query param", async () => {
|
||||
await locationsApi.getSites("Dallas");
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith(API_PATHS.location.sites, {
|
||||
searchParams: { search: "Dallas" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -3,16 +3,31 @@ import { API_PATHS } from "@/api/api-paths";
|
|||
|
||||
const apiGet = vi.fn();
|
||||
const apiPut = vi.fn();
|
||||
const apiPost = vi.fn();
|
||||
const apiPostForm = vi.fn();
|
||||
const apiPatch = vi.fn();
|
||||
const apiPatchFn = vi.fn();
|
||||
const apiPostFn = vi.fn();
|
||||
const apiDeleteFn = vi.fn();
|
||||
|
||||
vi.mock("@/api/api", () => ({
|
||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||
apiPut: (...args: unknown[]) => apiPut(...args),
|
||||
apiPost: vi.fn(),
|
||||
apiPost: (...args: unknown[]) => apiPost(...args),
|
||||
apiPostForm: (...args: unknown[]) => apiPostForm(...args),
|
||||
apiPatch: (...args: unknown[]) => apiPatch(...args),
|
||||
apiDelete: vi.fn(),
|
||||
apiPostNoContent: vi.fn(),
|
||||
api: {
|
||||
patch: (...args: unknown[]) => apiPatchFn(...args),
|
||||
post: (...args: unknown[]) => apiPostFn(...args),
|
||||
delete: (...args: unknown[]) => apiDeleteFn(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import { createDefaultWorkOrderFormValues } from "@/domain/work-orders/types/work-order";
|
||||
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
describe("workOrdersApi.getById", () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -50,3 +65,558 @@ describe("workOrdersApi.getById", () => {
|
|||
expect(apiGet).not.toHaveBeenCalledWith(`${API_PATHS.workOrder.getById}/10`, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.getBoardDetail", () => {
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
apiGet.mockResolvedValue({
|
||||
info: {
|
||||
id: 123,
|
||||
woNumber: "00000000123",
|
||||
workOrderType: 2,
|
||||
siteCode: "SITE-01",
|
||||
lifecycleStatus: 3,
|
||||
lifecycleStatusLabel: "Scheduled",
|
||||
},
|
||||
completion: { docStatus: 1 },
|
||||
comments: [],
|
||||
audit: [],
|
||||
media: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("GETs workorders/{id}/detail and maps nested sections", async () => {
|
||||
const result = await workOrdersApi.getBoardDetail(123);
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith(API_PATHS.workOrder.boardDetail(123));
|
||||
expect(result.info.woNumber).toBe("00000000123");
|
||||
expect(result.info.type).toBe("PM");
|
||||
expect(result.completion.docStatus).toBe("Yes");
|
||||
expect(result.comments).toEqual([]);
|
||||
expect(result.audit).toEqual([]);
|
||||
expect(result.media).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.createFromBoard", () => {
|
||||
beforeEach(() => {
|
||||
apiPost.mockReset();
|
||||
apiPost.mockResolvedValue({
|
||||
Id: 42,
|
||||
title: "Board WO",
|
||||
status: "Open",
|
||||
workOrderNumber: "20260625001",
|
||||
});
|
||||
});
|
||||
|
||||
it("posts camelCase board payload to workorders/board", async () => {
|
||||
await workOrdersApi.createFromBoard({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM",
|
||||
siteCode: "BK5",
|
||||
woNumber: "12345",
|
||||
});
|
||||
|
||||
expect(apiPost).toHaveBeenCalledWith(API_PATHS.workOrder.boardCreate, {
|
||||
workOrderType: 2,
|
||||
siteCode: "BK5",
|
||||
woNumber: "12345",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.update", () => {
|
||||
beforeEach(() => {
|
||||
apiPost.mockReset();
|
||||
apiPostForm.mockReset();
|
||||
apiPostForm.mockResolvedValue({ message: "Updated Successfully", status: "200" });
|
||||
});
|
||||
|
||||
it("posts multipart FormData to EditWorkorder, not JSON", async () => {
|
||||
const values = {
|
||||
...createDefaultWorkOrderFormValues(),
|
||||
title: "Updated title",
|
||||
description: "Updated description",
|
||||
status: "On Hold" as const,
|
||||
priority: "Sev 2",
|
||||
locationId: "8",
|
||||
dueDate: "2026-07-15",
|
||||
assignedTo: "Dispatcher",
|
||||
contactIds: [1, 2],
|
||||
categoryIds: [3],
|
||||
};
|
||||
|
||||
await workOrdersApi.update(2, values);
|
||||
|
||||
expect(apiPost).not.toHaveBeenCalled();
|
||||
expect(apiPostForm).toHaveBeenCalledTimes(1);
|
||||
expect(apiPostForm).toHaveBeenCalledWith(
|
||||
API_PATHS.workOrder.editWorkorder,
|
||||
expect.any(FormData),
|
||||
);
|
||||
|
||||
const formData = apiPostForm.mock.calls[0]?.[1] as FormData;
|
||||
expect(formData.get("Id")).toBe("2");
|
||||
expect(formData.get("WorkerOrderTitle")).toBe("Updated title");
|
||||
expect(formData.get("Status")).toBe("OnHold");
|
||||
expect(formData.get("Priority")).toBe("High");
|
||||
expect(formData.getAll("ContactIds")).toEqual(["1", "2"]);
|
||||
expect(formData.getAll("CategoryIds")).toEqual(["3"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.fetchDispatchers", () => {
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
});
|
||||
|
||||
it("loads dispatchers from board lookup endpoint", async () => {
|
||||
apiGet.mockResolvedValue([{ id: "guid-1", name: "Alex" }]);
|
||||
|
||||
const users = await workOrdersApi.fetchDispatchers();
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith(API_PATHS.workOrder.lookupsDispatchers);
|
||||
expect(users).toEqual([{ id: "guid-1", name: "Alex" }]);
|
||||
});
|
||||
|
||||
it("falls back to User list when lookup is empty", async () => {
|
||||
apiGet.mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: "u-1", name: "Fallback User" }]);
|
||||
|
||||
const users = await workOrdersApi.fetchDispatchers();
|
||||
|
||||
expect(apiGet).toHaveBeenNthCalledWith(1, API_PATHS.workOrder.lookupsDispatchers);
|
||||
expect(apiGet).toHaveBeenNthCalledWith(2, API_PATHS.user.list);
|
||||
expect(users).toEqual([{ id: "u-1", name: "Fallback User" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.getBoard", () => {
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
apiGet.mockResolvedValue({
|
||||
weekStart: "2026-07-13",
|
||||
weekEnd: "2026-07-17",
|
||||
counts: { returned: 1, total: 2 },
|
||||
unscheduled: [
|
||||
{
|
||||
id: 101,
|
||||
woNumber: "10000001234",
|
||||
workOrderType: 2,
|
||||
siteCode: "BK5",
|
||||
lifecycleStatusLabel: "Incomplete",
|
||||
scheduledDate: null,
|
||||
pm: "HVAC",
|
||||
},
|
||||
],
|
||||
scheduled: [
|
||||
{
|
||||
id: 202,
|
||||
woNumber: "10000001235",
|
||||
workOrderType: 6,
|
||||
siteCode: "BK5",
|
||||
lifecycleStatusLabel: "Scheduled",
|
||||
scheduledDate: "2026-07-15T00:00:00",
|
||||
vendorName: "Acme HVAC LLC",
|
||||
apptTime: "07:00 – 09:00",
|
||||
dueDate: "2026-07-18T00:00:00",
|
||||
pm: "PM Filter",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("calls board endpoint and maps scheduled/unscheduled rows", async () => {
|
||||
const result = await workOrdersApi.getBoard({
|
||||
weekStart: "2026-07-13",
|
||||
weekEnd: "2026-07-17",
|
||||
});
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith(
|
||||
API_PATHS.workOrder.board,
|
||||
expect.objectContaining({
|
||||
searchParams: expect.any(URLSearchParams),
|
||||
}),
|
||||
);
|
||||
expect(result.returnedCount).toBe(1);
|
||||
expect(result.totalCount).toBe(2);
|
||||
expect(result.unscheduled).toHaveLength(1);
|
||||
expect(result.scheduled).toHaveLength(1);
|
||||
expect(result.scheduled[0]?.company).toBe("Acme HVAC LLC");
|
||||
expect(result.scheduled[0]?.apptTime).toBe("07:00 – 09:00");
|
||||
expect(result.scheduled[0]?.type).toBe("Reactive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.searchBoard", () => {
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
apiGet.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 202,
|
||||
woNumber: "10000001235",
|
||||
workOrderType: 6,
|
||||
scheduledDate: "2026-07-15T00:00:00",
|
||||
vendorName: "Acme HVAC LLC",
|
||||
apptTime: "07:00 – 09:00",
|
||||
dueDate: "2026-07-18T00:00:00",
|
||||
pm: "PM Filter",
|
||||
lifecycleStatusLabel: "Scheduled",
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("calls board/search and maps paged items", async () => {
|
||||
const result = await workOrdersApi.searchBoard({
|
||||
datePreset: "Custom",
|
||||
dateFrom: "2026-07-13",
|
||||
dateTo: "2026-07-17",
|
||||
page: 0,
|
||||
pageSize: 100,
|
||||
});
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith(
|
||||
API_PATHS.workOrder.boardSearch,
|
||||
expect.objectContaining({
|
||||
searchParams: expect.any(URLSearchParams),
|
||||
}),
|
||||
);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.totalCount).toBe(1);
|
||||
expect(result.items[0]?.pm).toBe("PM Filter");
|
||||
expect(result.items[0]?.scheduledOn).toBe("2026-07-15");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.patchBoardField", () => {
|
||||
beforeEach(() => {
|
||||
apiPatchFn.mockReset();
|
||||
});
|
||||
|
||||
it("PATCHes board field and maps row with versions", async () => {
|
||||
apiPatchFn.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 10,
|
||||
siteCode: "BK5",
|
||||
lifecycleStatusLabel: "Scheduled",
|
||||
rowVersion: "AAAAAAAB",
|
||||
dispatchRowVersion: null,
|
||||
primaryDispatchId: null,
|
||||
vendorId: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const row = await workOrdersApi.patchBoardField(10, {
|
||||
field: "siteCode",
|
||||
value: "BK5",
|
||||
workOrderVersion: "AAAAAAA=",
|
||||
});
|
||||
|
||||
expect(apiPatchFn).toHaveBeenCalledWith(API_PATHS.workOrder.boardPatch(10), {
|
||||
json: {
|
||||
field: "siteCode",
|
||||
value: "BK5",
|
||||
workOrderVersion: "AAAAAAA=",
|
||||
},
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
expect(row.site).toBe("BK5");
|
||||
expect(row.rowVersion).toBe("AAAAAAAB");
|
||||
});
|
||||
|
||||
it("throws BoardPatchConflictError with currentState on 409", async () => {
|
||||
apiPatchFn.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({
|
||||
message: "The record was modified by another user. Refresh and retry.",
|
||||
currentState: {
|
||||
id: 10,
|
||||
siteCode: "OTHER",
|
||||
rowVersion: "NEWVER==",
|
||||
lifecycleStatusLabel: "Scheduled",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const { BoardPatchConflictError } =
|
||||
await import("@/domain/work-orders/errors/board-patch-errors");
|
||||
|
||||
await expect(
|
||||
workOrdersApi.patchBoardField(10, {
|
||||
field: "siteCode",
|
||||
value: "BK5",
|
||||
workOrderVersion: "STALE==",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BoardPatchConflictError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.createComment", () => {
|
||||
beforeEach(() => {
|
||||
apiPost.mockReset();
|
||||
apiPost.mockResolvedValue({
|
||||
id: 1,
|
||||
authorId: "guid",
|
||||
authorName: "Admin Test",
|
||||
text: "Called vendor",
|
||||
time: "2026-06-01T12:00:00.0000000Z",
|
||||
documents: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("POSTs { text } to workorders/{id}/comments", async () => {
|
||||
const comment = await workOrdersApi.createComment(55, "Called vendor");
|
||||
|
||||
expect(apiPost).toHaveBeenCalledWith(API_PATHS.workOrder.comments(55), {
|
||||
text: "Called vendor",
|
||||
});
|
||||
expect(comment.commenttext).toBe("Called vendor");
|
||||
expect(comment.userName).toBe("Admin Test");
|
||||
expect(comment.authorId).toBe("guid");
|
||||
expect(comment.createdDate).toBe("2026-06-01T12:00:00.0000000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.updateComment", () => {
|
||||
beforeEach(() => {
|
||||
apiPatchFn.mockReset();
|
||||
});
|
||||
|
||||
it("PATCHes JSON { text } to workorders/{id}/comments/{commentId}", async () => {
|
||||
apiPatchFn.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
id: 3,
|
||||
authorId: "guid",
|
||||
authorName: "Admin Test",
|
||||
text: "Updated text",
|
||||
time: "2026-06-01T12:00:00.0000000Z",
|
||||
documents: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const comment = await workOrdersApi.updateComment(55, 3, "Updated text");
|
||||
|
||||
expect(apiPatchFn).toHaveBeenCalledWith(API_PATHS.workOrder.commentItem(55, 3), {
|
||||
json: { text: "Updated text" },
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
expect(comment.commenttext).toBe("Updated text");
|
||||
expect(comment.userName).toBe("Admin Test");
|
||||
expect(comment.authorId).toBe("guid");
|
||||
});
|
||||
|
||||
it("accepts 204 No Content without a response body", async () => {
|
||||
apiPatchFn.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 204,
|
||||
json: async () => {
|
||||
throw new Error("no body");
|
||||
},
|
||||
});
|
||||
|
||||
const comment = await workOrdersApi.updateComment(2, 2, "zzzzzzzzzzzzzzzzaaaa");
|
||||
|
||||
expect(apiPatchFn).toHaveBeenCalledWith(API_PATHS.workOrder.commentItem(2, 2), {
|
||||
json: { text: "zzzzzzzzzzzzzzzzaaaa" },
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
expect(comment.id).toBe(2);
|
||||
expect(comment.commenttext).toBe("zzzzzzzzzzzzzzzzaaaa");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.uploadMedia", () => {
|
||||
beforeEach(() => {
|
||||
apiPostFn.mockReset();
|
||||
});
|
||||
|
||||
it("POSTs multipart FormData with file and category", async () => {
|
||||
apiPostFn.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 12,
|
||||
category: 3,
|
||||
url: "https://cdn.example.com/extra.jpg",
|
||||
fileName: "extra.jpg",
|
||||
uploadedAt: "2026-07-16T12:00:00Z",
|
||||
isLegacy: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const file = new File(["bytes"], "extra.jpg", { type: "image/jpeg" });
|
||||
const item = await workOrdersApi.uploadMedia(10, file, "Extra");
|
||||
|
||||
expect(apiPostFn).toHaveBeenCalledWith(
|
||||
API_PATHS.workOrder.media(10),
|
||||
expect.objectContaining({ throwHttpErrors: false }),
|
||||
);
|
||||
const body = apiPostFn.mock.calls[0]?.[1]?.body as FormData;
|
||||
expect(body.get("category")).toBe("Extra");
|
||||
expect(body.get("file")).toBeInstanceOf(File);
|
||||
expect(item.id).toBe("12");
|
||||
expect(item.category).toBe("Extra");
|
||||
});
|
||||
|
||||
it("throws BoardPatchValidationError on 422", async () => {
|
||||
apiPostFn.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 422,
|
||||
json: async () => ({
|
||||
code: "ReadOnly",
|
||||
message: "Work order is read-only.",
|
||||
}),
|
||||
});
|
||||
|
||||
const { BoardPatchValidationError } =
|
||||
await import("@/domain/work-orders/errors/board-patch-errors");
|
||||
|
||||
await expect(
|
||||
workOrdersApi.uploadMedia(10, new File(["x"], "a.jpg", { type: "image/jpeg" }), "Before"),
|
||||
).rejects.toMatchObject({
|
||||
name: "BoardPatchValidationError",
|
||||
code: "ReadOnly",
|
||||
});
|
||||
expect(BoardPatchValidationError).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.deleteMedia", () => {
|
||||
beforeEach(() => {
|
||||
apiDeleteFn.mockReset();
|
||||
});
|
||||
|
||||
it("DELETEs media item and accepts 204", async () => {
|
||||
apiDeleteFn.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 204,
|
||||
json: async () => {
|
||||
throw new Error("no body");
|
||||
},
|
||||
});
|
||||
|
||||
await workOrdersApi.deleteMedia(10, 12);
|
||||
|
||||
expect(apiDeleteFn).toHaveBeenCalledWith(API_PATHS.workOrder.mediaItem(10, 12), {
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects legacy media ids client-side", async () => {
|
||||
const { BoardPatchValidationError } =
|
||||
await import("@/domain/work-orders/errors/board-patch-errors");
|
||||
|
||||
await expect(workOrdersApi.deleteMedia(10, -1)).rejects.toBeInstanceOf(
|
||||
BoardPatchValidationError,
|
||||
);
|
||||
expect(apiDeleteFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.uploadCompletionDoc", () => {
|
||||
beforeEach(() => {
|
||||
apiPostFn.mockReset();
|
||||
});
|
||||
|
||||
it("POSTs FormData with file and workOrderVersion", async () => {
|
||||
apiPostFn.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
docStatus: 1,
|
||||
template: null,
|
||||
signOffName: "Jane Doe",
|
||||
signOffAttachment: "https://cdn.example.com/signed.pdf",
|
||||
signOffSignature: null,
|
||||
dispatchSignoffs: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const file = new File(["%PDF"], "signed.pdf", { type: "application/pdf" });
|
||||
const completion = await workOrdersApi.uploadCompletionDoc(10, {
|
||||
file,
|
||||
workOrderVersion: "AAAAAAAB",
|
||||
signOffName: "Jane Doe",
|
||||
});
|
||||
|
||||
const body = apiPostFn.mock.calls[0]?.[1]?.body as FormData;
|
||||
expect(apiPostFn).toHaveBeenCalledWith(
|
||||
API_PATHS.workOrder.completionDoc(10),
|
||||
expect.objectContaining({ throwHttpErrors: false }),
|
||||
);
|
||||
expect(body.get("workOrderVersion")).toBe("AAAAAAAB");
|
||||
expect(body.get("signOffName")).toBe("Jane Doe");
|
||||
expect(body.get("file")).toBeInstanceOf(File);
|
||||
expect(completion.docStatus).toBe("Yes");
|
||||
expect(completion.signOffAttachment).toBe("https://cdn.example.com/signed.pdf");
|
||||
});
|
||||
|
||||
it("throws BoardPatchConflictError on 409", async () => {
|
||||
apiPostFn.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({
|
||||
code: "ConcurrencyConflict",
|
||||
message: "The record was modified by another user. Refresh and retry.",
|
||||
}),
|
||||
});
|
||||
|
||||
const { BoardPatchConflictError } =
|
||||
await import("@/domain/work-orders/errors/board-patch-errors");
|
||||
|
||||
await expect(
|
||||
workOrdersApi.uploadCompletionDoc(10, {
|
||||
file: new File(["%PDF"], "signed.pdf", { type: "application/pdf" }),
|
||||
workOrderVersion: "STALE==",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BoardPatchConflictError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.fetchVendorsDropdown", () => {
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
apiGet.mockResolvedValue([
|
||||
{
|
||||
id: 7,
|
||||
companyName: "Cool Air LLC",
|
||||
contactName: "Ana Silva",
|
||||
tradeSpecialties: "HVAC",
|
||||
address: "100 Main St, Miami, FL",
|
||||
distanceMiles: 4.2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("calls vendors/Dropdown without filters", async () => {
|
||||
const vendors = await workOrdersApi.fetchVendorsDropdown();
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.dropdown, {
|
||||
searchParams: undefined,
|
||||
});
|
||||
expect(vendors).toEqual([
|
||||
{
|
||||
id: 7,
|
||||
companyName: "Cool Air LLC",
|
||||
contactName: "Ana Silva",
|
||||
tradeSpecialties: "HVAC",
|
||||
address: "100 Main St, Miami, FL",
|
||||
distanceMiles: 4.2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes optional trade and siteZip query params", async () => {
|
||||
await workOrdersApi.fetchVendorsDropdown("HVAC", "33101");
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.dropdown, {
|
||||
searchParams: { trade: "HVAC", siteZip: "33101" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { mapVendorDropdownItem } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import {
|
||||
formatVendorDistance,
|
||||
formatVendorTechnicianLabel,
|
||||
} from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||
|
||||
describe("mapVendorDropdownItem", () => {
|
||||
it("maps camelCase dropdown payload", () => {
|
||||
expect(
|
||||
mapVendorDropdownItem({
|
||||
id: 7,
|
||||
companyName: "Cool Air LLC",
|
||||
contactName: "Ana Silva",
|
||||
tradeSpecialties: "HVAC",
|
||||
address: "100 Main St",
|
||||
distanceMiles: 4.2,
|
||||
}),
|
||||
).toEqual({
|
||||
id: 7,
|
||||
companyName: "Cool Air LLC",
|
||||
contactName: "Ana Silva",
|
||||
tradeSpecialties: "HVAC",
|
||||
address: "100 Main St",
|
||||
distanceMiles: 4.2,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps PascalCase dropdown payload", () => {
|
||||
expect(
|
||||
mapVendorDropdownItem({
|
||||
Id: "v-1",
|
||||
CompanyName: "Rooter Pros",
|
||||
ContactName: "Jon",
|
||||
TradeSpecialties: "Plumbing",
|
||||
Address: "200 Ocean Dr",
|
||||
DistanceMiles: "12",
|
||||
}),
|
||||
).toEqual({
|
||||
id: "v-1",
|
||||
companyName: "Rooter Pros",
|
||||
contactName: "Jon",
|
||||
tradeSpecialties: "Plumbing",
|
||||
address: "200 Ocean Dr",
|
||||
distanceMiles: 12,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatVendorTechnicianLabel", () => {
|
||||
it("formats contactName · companyName", () => {
|
||||
expect(
|
||||
formatVendorTechnicianLabel({
|
||||
id: 1,
|
||||
companyName: "Gateway Plumbing",
|
||||
contactName: "Adam Whyte",
|
||||
tradeSpecialties: "",
|
||||
address: "",
|
||||
distanceMiles: null,
|
||||
}),
|
||||
).toBe("Adam Whyte · Gateway Plumbing");
|
||||
});
|
||||
|
||||
it("falls back to company when contact is missing", () => {
|
||||
expect(
|
||||
formatVendorTechnicianLabel({
|
||||
id: 2,
|
||||
companyName: "Gateway Plumbing",
|
||||
contactName: "",
|
||||
tradeSpecialties: "",
|
||||
address: "",
|
||||
distanceMiles: null,
|
||||
}),
|
||||
).toBe("Gateway Plumbing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatVendorDistance", () => {
|
||||
it("formats miles", () => {
|
||||
expect(formatVendorDistance(4.2)).toBe("4.2 mi");
|
||||
expect(formatVendorDistance(5)).toBe("5 mi");
|
||||
expect(formatVendorDistance(null)).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
@ -68,25 +68,71 @@ describe("wizard step validation", () => {
|
|||
expect(isWizardStepValid(1, EMPTY_WIZARD_DRAFT)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows step 1 continue when type is selected", () => {
|
||||
it("allows step 1 when type is set without woNumber", () => {
|
||||
const draft = { ...EMPTY_WIZARD_DRAFT, type: "PM" as const };
|
||||
expect(canContinueWizardStep1(draft)).toBe(true);
|
||||
expect(isWizardStepValid(1, draft)).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks create without location", () => {
|
||||
const draft = { ...EMPTY_WIZARD_DRAFT, type: "PM" as const };
|
||||
it("allows step 1 when type and woNumber are set (schedule optional)", () => {
|
||||
const draft = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM" as const,
|
||||
woNumber: "12345",
|
||||
};
|
||||
expect(canContinueWizardStep1(draft)).toBe(true);
|
||||
expect(isWizardStepValid(1, draft)).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks create without siteCode", () => {
|
||||
const draft = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM" as const,
|
||||
woNumber: "12345",
|
||||
locationId: "99",
|
||||
};
|
||||
expect(canCreateWorkOrderFromWizard(draft)).toBe(false);
|
||||
expect(isWizardStepValid(2, draft)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows create when location is selected", () => {
|
||||
it("allows create when type and siteCode are set (woNumber optional)", () => {
|
||||
const draft = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM" as const,
|
||||
locationId: "99",
|
||||
siteCode: "BK5",
|
||||
};
|
||||
expect(canCreateWorkOrderFromWizard(draft)).toBe(true);
|
||||
expect(isWizardStepValid(2, draft)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows create when type, woNumber, and siteCode are set", () => {
|
||||
const draft = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM" as const,
|
||||
woNumber: "12345678901234567890",
|
||||
locationId: "99",
|
||||
siteCode: "BK5",
|
||||
};
|
||||
expect(canCreateWorkOrderFromWizard(draft)).toBe(true);
|
||||
expect(isWizardStepValid(2, draft)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows step 1 when woNumber is empty", () => {
|
||||
const draft = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM" as const,
|
||||
woNumber: "",
|
||||
};
|
||||
expect(canContinueWizardStep1(draft)).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks step 1 when woNumber is non-numeric", () => {
|
||||
const draft = {
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM" as const,
|
||||
woNumber: "abc",
|
||||
};
|
||||
expect(canContinueWizardStep1(draft)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,208 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
boardMediaItemsToMediaFiles,
|
||||
buildCompletionDocFormData,
|
||||
buildMediaFormData,
|
||||
mapBoardDetailMediaItem,
|
||||
mapWorkOrderBoardDetail,
|
||||
} from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
|
||||
const SAMPLE_DETAIL = {
|
||||
info: {
|
||||
id: 123,
|
||||
woNumber: "00000000123",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
workOrderType: 2,
|
||||
isPastDue: false,
|
||||
siteCode: "SITE-01",
|
||||
locationName: "Main Warehouse",
|
||||
pocName: "John Doe",
|
||||
pocPhone: "555-0100",
|
||||
pocNotes: null,
|
||||
lifecycleStatus: 3,
|
||||
lifecycleStatusLabel: "Scheduled",
|
||||
legacyStatus: null,
|
||||
dispatcherId: "user-guid",
|
||||
dispatcherName: "Jane Dispatcher",
|
||||
initials: "JD",
|
||||
color: "#3B82F6",
|
||||
dueDate: "2026-07-20T00:00:00Z",
|
||||
scheduledDate: "2026-07-16T00:00:00Z",
|
||||
targetWeek: "2026-07-13",
|
||||
scheduleWeekOnly: false,
|
||||
dayGroup: "Wed",
|
||||
vendorId: 45,
|
||||
vendorName: "ACME HVAC",
|
||||
techName: "Mike Tech",
|
||||
techPhone: "555-0200",
|
||||
apptDate: "2026-07-16T14:00:00Z",
|
||||
apptTime: "10:00 AM",
|
||||
pm: "HVAC PM",
|
||||
serviceNotes: "Check filters",
|
||||
extraServices: ["Filter Change"],
|
||||
docStatus: 2,
|
||||
flagColor: "#EF4444",
|
||||
primaryDispatchId: 789,
|
||||
rowVersion: "AQAAAAAAAQE=",
|
||||
dispatchRowVersion: "AQAAAAAAAQE=",
|
||||
},
|
||||
completion: {
|
||||
docStatus: 2,
|
||||
template: {
|
||||
id: 1,
|
||||
name: "HVAC PM Completion",
|
||||
serviceKey: "HVAC PM",
|
||||
workOrderType: 2,
|
||||
templateUrl: "https://example.com/hvac.pdf",
|
||||
isActive: true,
|
||||
},
|
||||
signOffName: null,
|
||||
signOffAttachment: null,
|
||||
signOffSignature: null,
|
||||
dispatchSignoffs: [
|
||||
{
|
||||
dispatchId: 789,
|
||||
signoffType: "Customer",
|
||||
name: "Site Contact",
|
||||
signedAt: "2026-07-16T15:30:00.0000000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
comments: [
|
||||
{
|
||||
id: 1,
|
||||
authorId: "user-1",
|
||||
text: "Called vendor",
|
||||
time: "2026-06-01T12:00:00.0000000Z",
|
||||
documents: null,
|
||||
},
|
||||
],
|
||||
audit: [
|
||||
{
|
||||
type: "system",
|
||||
dispatcherId: null,
|
||||
action: "WeekRolled",
|
||||
fieldName: "CarriedOver",
|
||||
oldValue: "0",
|
||||
newValue: "1",
|
||||
time: "2026-07-16T10:00:00.0000000Z",
|
||||
dispatchId: null,
|
||||
},
|
||||
],
|
||||
media: [
|
||||
{
|
||||
id: 10,
|
||||
category: 1,
|
||||
url: "https://cdn.example.com/before.jpg",
|
||||
fileName: "before.jpg",
|
||||
uploadedAt: "2026-07-15T18:00:00.0000000Z",
|
||||
isLegacy: false,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
category: 3,
|
||||
url: "https://cdn.example.com/extra.pdf",
|
||||
fileName: "extra.pdf",
|
||||
uploadedAt: "2026-07-15T19:00:00.0000000Z",
|
||||
isLegacy: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("mapWorkOrderBoardDetail", () => {
|
||||
it("maps nested info/completion/comments/audit/media", () => {
|
||||
const detail = mapWorkOrderBoardDetail(SAMPLE_DETAIL);
|
||||
|
||||
expect(detail.info.id).toBe(123);
|
||||
expect(detail.info.woNumber).toBe("00000000123");
|
||||
expect(detail.info.site).toBe("SITE-01");
|
||||
expect(detail.info.type).toBe("PM");
|
||||
expect(detail.info.status).toBe("Scheduled");
|
||||
expect(detail.info.docStatus).toBe("No");
|
||||
expect(detail.info.company).toBe("ACME HVAC");
|
||||
expect(detail.info.tech).toBe("Mike Tech");
|
||||
|
||||
expect(detail.completion.docStatus).toBe("No");
|
||||
expect(detail.completion.template?.name).toBe("HVAC PM Completion");
|
||||
expect(detail.completion.dispatchSignoffs).toHaveLength(1);
|
||||
expect(detail.completion.dispatchSignoffs[0]?.signoffType).toBe("Customer");
|
||||
|
||||
expect(detail.comments).toHaveLength(1);
|
||||
expect(detail.comments[0]?.commenttext).toBe("Called vendor");
|
||||
expect(detail.comments[0]?.createdDate).toBe("2026-06-01T12:00:00.0000000Z");
|
||||
expect(detail.comments[0]?.authorId).toBe("user-1");
|
||||
expect(detail.comments[0]?.userName).toBe("");
|
||||
|
||||
expect(detail.audit).toHaveLength(1);
|
||||
expect(detail.audit[0]?.action).toBe("WeekRolled");
|
||||
expect(detail.audit[0]?.userName).toBe("System");
|
||||
expect(detail.audit[0]?.createdAt).toBe("2026-07-16T10:00:00.0000000Z");
|
||||
|
||||
expect(detail.media).toHaveLength(2);
|
||||
expect(detail.media[0]?.category).toBe("Before");
|
||||
expect(detail.media[1]?.category).toBe("Extra");
|
||||
});
|
||||
|
||||
it("maps authorName to userName and keeps authorId separate", () => {
|
||||
const detail = mapWorkOrderBoardDetail({
|
||||
...SAMPLE_DETAIL,
|
||||
comments: [
|
||||
{
|
||||
id: 2,
|
||||
authorId: "guid-abc",
|
||||
authorName: "Jane Dispatcher",
|
||||
text: "On site",
|
||||
time: "2026-06-02T12:00:00.0000000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(detail.comments[0]?.userName).toBe("Jane Dispatcher");
|
||||
expect(detail.comments[0]?.authorId).toBe("guid-abc");
|
||||
});
|
||||
|
||||
it("converts board media to MediaFile for UI", () => {
|
||||
const detail = mapWorkOrderBoardDetail(SAMPLE_DETAIL);
|
||||
const files = boardMediaItemsToMediaFiles(detail.media);
|
||||
expect(files[0]).toMatchObject({
|
||||
id: "10",
|
||||
name: "before.jpg",
|
||||
url: "https://cdn.example.com/before.jpg",
|
||||
category: "Photo",
|
||||
fileType: "image",
|
||||
isLegacy: false,
|
||||
});
|
||||
expect(files[1]?.category).toBe("Other");
|
||||
expect(files[1]?.fileType).toBe("pdf");
|
||||
});
|
||||
|
||||
it("maps numeric category and marks negative ids as legacy", () => {
|
||||
const legacy = mapBoardDetailMediaItem({
|
||||
id: -1,
|
||||
category: 1,
|
||||
url: "https://cdn.example.com/before.jpg",
|
||||
fileName: "before.jpg",
|
||||
});
|
||||
expect(legacy.id).toBe("-1");
|
||||
expect(legacy.category).toBe("Before");
|
||||
expect(legacy.isLegacy).toBe(true);
|
||||
});
|
||||
|
||||
it("builds media and completion FormData fields", () => {
|
||||
const mediaFile = new File(["img"], "a.jpg", { type: "image/jpeg" });
|
||||
const mediaForm = buildMediaFormData(mediaFile, "After");
|
||||
expect(mediaForm.get("category")).toBe("After");
|
||||
expect(mediaForm.get("file")).toBeInstanceOf(File);
|
||||
|
||||
const pdf = new File(["%PDF"], "doc.pdf", { type: "application/pdf" });
|
||||
const completionForm = buildCompletionDocFormData({
|
||||
file: pdf,
|
||||
workOrderVersion: "VER==",
|
||||
signOffName: "Jane",
|
||||
});
|
||||
expect(completionForm.get("workOrderVersion")).toBe("VER==");
|
||||
expect(completionForm.get("signOffName")).toBe("Jane");
|
||||
expect(completionForm.get("file")).toBeInstanceOf(File);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
mapWizardDraftToBoardCreatePayload,
|
||||
mapWizardTypeToApi,
|
||||
parseSiteCodeFromLocationName,
|
||||
resolveSiteCode,
|
||||
} from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
|
||||
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
|
||||
|
||||
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
describe("parseSiteCodeFromLocationName", () => {
|
||||
it("parses leading site code tokens", () => {
|
||||
expect(parseSiteCodeFromLocationName("BK5 · Dallas, TX")).toBe("BK5");
|
||||
|
||||
expect(parseSiteCodeFromLocationName("CHI-1042 — Chicago Loop")).toBe("CHI-1042");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSiteCode", () => {
|
||||
it("prefers explicit siteCode over name parse", () => {
|
||||
expect(resolveSiteCode({ siteCode: "BK5", name: "OTHER — City" })).toBe("BK5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapWizardTypeToApi", () => {
|
||||
it("maps wizard types to numeric enums", () => {
|
||||
expect(mapWizardTypeToApi("PM")).toBe(WORK_ORDER_TYPE_API.PM);
|
||||
|
||||
expect(mapWizardTypeToApi("Emergency")).toBe(WORK_ORDER_TYPE_API.Emergency);
|
||||
|
||||
expect(mapWizardTypeToApi("Reactive")).toBe(WORK_ORDER_TYPE_API.Reactive);
|
||||
|
||||
expect(mapWizardTypeToApi("Add-On")).toBe(WORK_ORDER_TYPE_API.AddOn);
|
||||
|
||||
expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Other);
|
||||
|
||||
expect(mapWizardTypeToApi("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapWizardDraftToBoardCreatePayload", () => {
|
||||
it("maps required create fields", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
||||
type: "PM",
|
||||
|
||||
siteCode: "BK5",
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
workOrderType: 2,
|
||||
|
||||
siteCode: "BK5",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps full wizard payload with structured service and notes fields", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
||||
type: "PM",
|
||||
|
||||
siteCode: "BK5",
|
||||
|
||||
woNumber: "12345",
|
||||
|
||||
assigneeId: "dispatcher-guid",
|
||||
|
||||
dueDate: "2026-07-01",
|
||||
|
||||
scheduledOn: "2026-06-25",
|
||||
|
||||
scheduleWeekOnly: false,
|
||||
|
||||
vendorId: "5",
|
||||
|
||||
apptStart: "09:00",
|
||||
|
||||
apptEnd: "11:00",
|
||||
|
||||
pm: "HVAC PM",
|
||||
|
||||
pmNote: "Check unit on roof",
|
||||
|
||||
extraServices: ["Filter change", "Coil clean"],
|
||||
|
||||
pocName: "Jane Site Lead",
|
||||
|
||||
pocPhone: "+1 555-0100",
|
||||
|
||||
pocNotes: "Call before arrival",
|
||||
|
||||
techPhone: "+1 555-0199",
|
||||
|
||||
vendorNotes: "Gate code 4421",
|
||||
|
||||
locationId: "12",
|
||||
});
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
workOrderType: 2,
|
||||
|
||||
siteCode: "BK5",
|
||||
|
||||
woNumber: "12345",
|
||||
|
||||
assignTo: "dispatcher-guid",
|
||||
|
||||
dueDate: "2026-07-01",
|
||||
|
||||
scheduledDate: "2026-06-25",
|
||||
|
||||
vendorId: 5,
|
||||
|
||||
apptDate: "2026-06-25",
|
||||
|
||||
apptTime: "09:00 – 11:00",
|
||||
|
||||
primaryService: "HVAC PM",
|
||||
|
||||
extraServices: ["Filter change", "Coil clean"],
|
||||
|
||||
serviceNotes: "Check unit on roof",
|
||||
|
||||
pocName: "Jane Site Lead",
|
||||
|
||||
pocPhone: "+1 555-0100",
|
||||
|
||||
pocNotes: "Call before arrival",
|
||||
|
||||
techPhone: "+1 555-0199",
|
||||
|
||||
vendorNotes: "Gate code 4421",
|
||||
|
||||
locationId: 12,
|
||||
});
|
||||
|
||||
expect(payload.trade).toBeUndefined();
|
||||
|
||||
expect(payload.description).toBeUndefined();
|
||||
|
||||
expect(payload.scheduleWeekOnly).toBeUndefined();
|
||||
|
||||
expect(payload.targetWeek).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps scheduleWeekOnly to targetWeek and omits scheduledDate", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
||||
type: "Reactive",
|
||||
|
||||
siteCode: "BK5",
|
||||
|
||||
scheduledOn: "2026-06-25",
|
||||
|
||||
scheduleWeekOnly: true,
|
||||
});
|
||||
|
||||
expect(payload.scheduleWeekOnly).toBe(true);
|
||||
|
||||
expect(payload.targetWeek).toBe("2026-06-22");
|
||||
|
||||
expect(payload.scheduledDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers explicit targetWeek when scheduleWeekOnly and scheduledOn is empty", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
||||
type: "PM",
|
||||
|
||||
siteCode: "BK5",
|
||||
|
||||
scheduledOn: "",
|
||||
|
||||
scheduleWeekOnly: true,
|
||||
|
||||
targetWeek: "2026-06-22",
|
||||
});
|
||||
|
||||
expect(payload.scheduleWeekOnly).toBe(true);
|
||||
|
||||
expect(payload.targetWeek).toBe("2026-06-22");
|
||||
|
||||
expect(payload.scheduledDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits appointment fields when vendor is missing but still sends techPhone and vendorNotes", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
||||
type: "PM",
|
||||
|
||||
siteCode: "BK5",
|
||||
|
||||
scheduledOn: "2026-06-25",
|
||||
|
||||
apptStart: "09:00",
|
||||
|
||||
apptEnd: "11:00",
|
||||
|
||||
techPhone: "+1 555-0199",
|
||||
|
||||
vendorNotes: "Leave at dock",
|
||||
});
|
||||
|
||||
expect(payload.vendorId).toBeUndefined();
|
||||
|
||||
expect(payload.apptDate).toBeUndefined();
|
||||
|
||||
expect(payload.apptTime).toBeUndefined();
|
||||
|
||||
expect(payload.techPhone).toBe("+1 555-0199");
|
||||
|
||||
expect(payload.vendorNotes).toBe("Leave at dock");
|
||||
});
|
||||
|
||||
it("derives siteCode from locationName when draft.siteCode is empty", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
||||
type: "Emergency",
|
||||
|
||||
locationName: "BK5 · Dallas, TX",
|
||||
});
|
||||
|
||||
expect(payload.siteCode).toBe("BK5");
|
||||
|
||||
expect(payload.workOrderType).toBe(3);
|
||||
});
|
||||
|
||||
it("throws when type is missing", () => {
|
||||
expect(() =>
|
||||
mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
||||
siteCode: "BK5",
|
||||
}),
|
||||
).toThrow("Work order type is required");
|
||||
});
|
||||
|
||||
it("throws when siteCode cannot be resolved", () => {
|
||||
expect(() =>
|
||||
mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
||||
type: "PM",
|
||||
}),
|
||||
).toThrow("Site code is required");
|
||||
});
|
||||
|
||||
it("omits scheduledDate for range schedule and puts range in description only", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
||||
type: "PM",
|
||||
|
||||
siteCode: "BK5",
|
||||
|
||||
scheduledOn: "2026-06-08",
|
||||
|
||||
scheduleEnd: "2026-06-12",
|
||||
|
||||
schedMode: "range",
|
||||
|
||||
pocNotes: "Should not be in description",
|
||||
|
||||
vendorNotes: "Also not in description",
|
||||
});
|
||||
|
||||
expect(payload.scheduledDate).toBeUndefined();
|
||||
|
||||
expect(payload.scheduleWeekOnly).toBeUndefined();
|
||||
|
||||
expect(payload.targetWeek).toBeUndefined();
|
||||
|
||||
expect(payload.description).toContain("Schedule range:");
|
||||
|
||||
expect(payload.description).toContain("Jun 8");
|
||||
|
||||
expect(payload.description).toContain("Jun 12");
|
||||
|
||||
expect(payload.description).not.toContain("Should not be in description");
|
||||
|
||||
expect(payload.description).not.toContain("Also not in description");
|
||||
|
||||
expect(payload.pocNotes).toBe("Should not be in description");
|
||||
|
||||
expect(payload.vendorNotes).toBe("Also not in description");
|
||||
});
|
||||
|
||||
it("maps service notes and extras as structured fields instead of description", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
||||
type: "PM",
|
||||
|
||||
siteCode: "BK5",
|
||||
|
||||
pm: "HVAC PM",
|
||||
|
||||
pmNote: "Check unit on roof",
|
||||
|
||||
extraServices: ["Filter change", "Coil clean", "Filter change"],
|
||||
});
|
||||
|
||||
expect(payload.primaryService).toBe("HVAC PM");
|
||||
|
||||
expect(payload.extraServices).toEqual(["Filter change", "Coil clean"]);
|
||||
|
||||
expect(payload.serviceNotes).toBe("Check unit on roof");
|
||||
|
||||
expect(payload.description).toBeUndefined();
|
||||
|
||||
expect(payload.trade).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps numeric WO numbers", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
||||
type: "PM",
|
||||
|
||||
siteCode: "BK5",
|
||||
|
||||
woNumber: "12345678901",
|
||||
});
|
||||
|
||||
expect(payload.woNumber).toBe("12345678901");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildBoardPatchPayload,
|
||||
expandTablePatchToBoardFieldOps,
|
||||
isLocalOnlyTablePatch,
|
||||
mapDocStatusToApi,
|
||||
} from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
|
||||
|
||||
describe("mapDocStatusToApi", () => {
|
||||
it("maps NN to NotNeeded", () => {
|
||||
expect(mapDocStatusToApi("NN")).toBe("NotNeeded");
|
||||
});
|
||||
|
||||
it("keeps Yes and No", () => {
|
||||
expect(mapDocStatusToApi("Yes")).toBe("Yes");
|
||||
expect(mapDocStatusToApi("No")).toBe("No");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLocalOnlyTablePatch", () => {
|
||||
it("treats mediaCount as local-only", () => {
|
||||
expect(isLocalOnlyTablePatch({ mediaCount: 3 })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat site as local-only", () => {
|
||||
expect(isLocalOnlyTablePatch({ site: "BK5" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandTablePatchToBoardFieldOps", () => {
|
||||
it("maps single fields to board field names", () => {
|
||||
expect(expandTablePatchToBoardFieldOps({ woNumber: "12345" })).toEqual([
|
||||
{ field: "woNumber", value: "12345" },
|
||||
]);
|
||||
expect(expandTablePatchToBoardFieldOps({ type: "PM" })).toEqual([
|
||||
{ field: "workOrderType", value: "PM" },
|
||||
]);
|
||||
expect(expandTablePatchToBoardFieldOps({ site: "BK5" })).toEqual([
|
||||
{ field: "siteCode", value: "BK5" },
|
||||
]);
|
||||
expect(expandTablePatchToBoardFieldOps({ dispatcherId: "guid-1" })).toEqual([
|
||||
{ field: "assignTo", value: "guid-1" },
|
||||
]);
|
||||
expect(expandTablePatchToBoardFieldOps({ status: "Scheduled" })).toEqual([
|
||||
{ field: "lifecycleStatus", value: "Scheduled" },
|
||||
]);
|
||||
expect(expandTablePatchToBoardFieldOps({ vendorId: "7" })).toEqual([
|
||||
{ field: "vendorId", value: "7" },
|
||||
]);
|
||||
expect(expandTablePatchToBoardFieldOps({ pm: "HVAC" })).toEqual([
|
||||
{ field: "pm", value: "HVAC" },
|
||||
]);
|
||||
expect(expandTablePatchToBoardFieldOps({ docStatus: "NN" })).toEqual([
|
||||
{ field: "docStatus", value: "NotNeeded" },
|
||||
]);
|
||||
expect(expandTablePatchToBoardFieldOps({ flagColor: "#EF4444" })).toEqual([
|
||||
{ field: "flagColor", value: "#EF4444" },
|
||||
]);
|
||||
expect(expandTablePatchToBoardFieldOps({ flagColor: null })).toEqual([
|
||||
{ field: "flagColor", value: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits service notes modal ops in pm → extraServices → serviceNotes order", () => {
|
||||
expect(
|
||||
expandTablePatchToBoardFieldOps({
|
||||
pm: "Backflow Preventers",
|
||||
extraServices: ["Filter change", "Coil clean"],
|
||||
serviceNotes: "service notes",
|
||||
}),
|
||||
).toEqual([
|
||||
{ field: "pm", value: "Backflow Preventers" },
|
||||
{ field: "extraServices", value: '["Filter change","Coil clean"]' },
|
||||
{ field: "serviceNotes", value: "service notes" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits week-only schedule ops in stable order", () => {
|
||||
expect(
|
||||
expandTablePatchToBoardFieldOps({
|
||||
scheduleWeekOnly: true,
|
||||
targetWeek: "2026-07-13",
|
||||
scheduledOn: "",
|
||||
}),
|
||||
).toEqual([
|
||||
{ field: "scheduleWeekOnly", value: "true" },
|
||||
{ field: "targetWeek", value: "2026-07-13" },
|
||||
{ field: "scheduledDate", value: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits day schedule ops clearing week-only", () => {
|
||||
expect(
|
||||
expandTablePatchToBoardFieldOps({
|
||||
scheduleWeekOnly: false,
|
||||
scheduledOn: "2026-07-15",
|
||||
targetWeek: "",
|
||||
}),
|
||||
).toEqual([
|
||||
{ field: "scheduleWeekOnly", value: "false" },
|
||||
{ field: "scheduledDate", value: "2026-07-15" },
|
||||
{ field: "targetWeek", value: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores local-only companion keys when expanding vendor/site", () => {
|
||||
expect(
|
||||
expandTablePatchToBoardFieldOps({
|
||||
vendorId: "7",
|
||||
company: "Acme",
|
||||
tech: "Ana",
|
||||
techPhone: "555",
|
||||
}),
|
||||
).toEqual([{ field: "vendorId", value: "7" }]);
|
||||
|
||||
expect(
|
||||
expandTablePatchToBoardFieldOps({
|
||||
site: "BK5",
|
||||
location: "Dallas",
|
||||
locationId: "12",
|
||||
pocName: "Pat",
|
||||
pocPhone: "111",
|
||||
pocNotes: "call",
|
||||
}),
|
||||
).toEqual([{ field: "siteCode", value: "BK5" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBoardPatchPayload", () => {
|
||||
it("includes dispatch version for dispatch fields", () => {
|
||||
const payload = buildBoardPatchPayload(
|
||||
{ field: "vendorId", value: "7" },
|
||||
{
|
||||
rowVersion: "wo-v1",
|
||||
dispatchRowVersion: "disp-v1",
|
||||
primaryDispatchId: 9,
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
field: "vendorId",
|
||||
value: "7",
|
||||
workOrderVersion: "wo-v1",
|
||||
dispatchVersion: "disp-v1",
|
||||
primaryDispatchId: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("omits dispatch version for WO-only fields", () => {
|
||||
const payload = buildBoardPatchPayload(
|
||||
{ field: "siteCode", value: "BK5" },
|
||||
{
|
||||
rowVersion: "wo-v1",
|
||||
dispatchRowVersion: "disp-v1",
|
||||
primaryDispatchId: 9,
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
field: "siteCode",
|
||||
value: "BK5",
|
||||
workOrderVersion: "wo-v1",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits dispatch version for flagColor", () => {
|
||||
const payload = buildBoardPatchPayload(
|
||||
{ field: "flagColor", value: "#8B5CF6" },
|
||||
{
|
||||
rowVersion: "wo-v1",
|
||||
dispatchRowVersion: "disp-v1",
|
||||
primaryDispatchId: 9,
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
field: "flagColor",
|
||||
value: "#8B5CF6",
|
||||
workOrderVersion: "wo-v1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mapWorkOrderFormToCreatePayload,
|
||||
mapWorkOrderFormToUpdatePayload,
|
||||
mapWorkOrderFormToUpdateFormData,
|
||||
} from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import { createDefaultWorkOrderFormValues } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
|
|
@ -32,36 +32,107 @@ describe("mapWorkOrderFormToCreatePayload", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("mapWorkOrderFormToUpdatePayload", () => {
|
||||
it("includes CreatedDate in update payload", () => {
|
||||
describe("mapWorkOrderFormToUpdateFormData", () => {
|
||||
it("builds multipart FormData with Id and persisted fields only", () => {
|
||||
const values = {
|
||||
...createDefaultWorkOrderFormValues(),
|
||||
workOrderNumber: "CUST-001",
|
||||
title: "Replace filter",
|
||||
locationId: "42",
|
||||
priority: "Sev 1",
|
||||
status: "Open" as const,
|
||||
status: "On Hold" as const,
|
||||
createdDate: "2026-01-15",
|
||||
dueDate: "2026-07-15",
|
||||
completedDate: "",
|
||||
assignedTo: "Dispatcher",
|
||||
description: "Updated description",
|
||||
po: "PO-1",
|
||||
problem: "Leak",
|
||||
source: "PM",
|
||||
};
|
||||
|
||||
const payload = mapWorkOrderFormToUpdatePayload(99, values);
|
||||
const formData = mapWorkOrderFormToUpdateFormData(99, values);
|
||||
|
||||
expect(payload.CreatedDate).toBe("2026-01-15");
|
||||
expect(payload.Id).toBe(99);
|
||||
expect(payload.WorkerOrderTitle).toBe("Replace filter");
|
||||
expect(formData.get("Id")).toBe("99");
|
||||
expect(formData.get("WorkerOrderTitle")).toBe("Replace filter");
|
||||
expect(formData.get("Description")).toBe("Updated description");
|
||||
expect(formData.get("Status")).toBe("OnHold");
|
||||
expect(formData.get("Priority")).toBe("Critical");
|
||||
expect(formData.get("LocationId")).toBe("42");
|
||||
expect(formData.get("AssignTo")).toBe("Dispatcher");
|
||||
expect(formData.get("DueDate")).toBe("2026-07-15");
|
||||
expect(formData.get("CompletedDate")).toBeNull();
|
||||
expect(formData.get("WorkerOrderNumber")).toBeNull();
|
||||
expect(formData.get("PO")).toBeNull();
|
||||
expect(formData.get("Problem")).toBeNull();
|
||||
expect(formData.get("ScheduledDate")).toBeNull();
|
||||
expect(formData.get("Source")).toBeNull();
|
||||
expect(formData.get("CreatedDate")).toBeNull();
|
||||
});
|
||||
|
||||
it("maps nullable optional fields", () => {
|
||||
it("maps form status and priority labels to edit enums", () => {
|
||||
const values = {
|
||||
...createDefaultWorkOrderFormValues(),
|
||||
createdDate: "2026-01-15",
|
||||
title: "WO",
|
||||
description: "Desc",
|
||||
status: "In Progress" as const,
|
||||
priority: "Sev 3",
|
||||
};
|
||||
|
||||
const payload = mapWorkOrderFormToUpdatePayload(1, values);
|
||||
const formData = mapWorkOrderFormToUpdateFormData(1, values);
|
||||
|
||||
expect(payload.CreatedDate).toBe("2026-01-15");
|
||||
expect(payload.PO).toBeNull();
|
||||
expect(payload.LocationId).toBeNull();
|
||||
expect(formData.get("Status")).toBe("InProgress");
|
||||
expect(formData.get("Priority")).toBe("Medium");
|
||||
});
|
||||
|
||||
it("omits empty optional fields for partial update", () => {
|
||||
const values = {
|
||||
...createDefaultWorkOrderFormValues(),
|
||||
title: "Title only",
|
||||
description: "",
|
||||
priority: "",
|
||||
assignedTo: "",
|
||||
dueDate: "",
|
||||
locationId: "",
|
||||
};
|
||||
|
||||
const formData = mapWorkOrderFormToUpdateFormData(1, values);
|
||||
|
||||
expect(formData.get("Id")).toBe("1");
|
||||
expect(formData.get("WorkerOrderTitle")).toBe("Title only");
|
||||
expect(formData.get("Status")).toBe("Open");
|
||||
expect(formData.get("Description")).toBeNull();
|
||||
expect(formData.get("Priority")).toBeNull();
|
||||
expect(formData.get("AssignTo")).toBeNull();
|
||||
expect(formData.get("DueDate")).toBeNull();
|
||||
expect(formData.get("LocationId")).toBeNull();
|
||||
});
|
||||
|
||||
it("appends ContactIds and CategoryIds when present", () => {
|
||||
const values = {
|
||||
...createDefaultWorkOrderFormValues(),
|
||||
title: "With contacts",
|
||||
description: "Desc",
|
||||
contactIds: [1, 2],
|
||||
categoryIds: [3],
|
||||
};
|
||||
|
||||
const formData = mapWorkOrderFormToUpdateFormData(2, values);
|
||||
|
||||
expect(formData.getAll("ContactIds")).toEqual(["1", "2"]);
|
||||
expect(formData.getAll("CategoryIds")).toEqual(["3"]);
|
||||
});
|
||||
|
||||
it("does not append ContactIds or CategoryIds when undefined", () => {
|
||||
const values = {
|
||||
...createDefaultWorkOrderFormValues(),
|
||||
title: "No lists",
|
||||
description: "Desc",
|
||||
};
|
||||
|
||||
const formData = mapWorkOrderFormToUpdateFormData(2, values);
|
||||
|
||||
expect(formData.getAll("ContactIds")).toEqual([]);
|
||||
expect(formData.getAll("CategoryIds")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||
import {
|
||||
mapUpliftRequest,
|
||||
mapWorkOrderDispatchSummary,
|
||||
mapWorkOrderTableRow,
|
||||
} from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
|
||||
describe("mapWorkOrderDispatchSummary", () => {
|
||||
|
|
@ -66,3 +67,56 @@ describe("mapUpliftRequest", () => {
|
|||
expect(result.canDecide).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapWorkOrderTableRow (board DTO)", () => {
|
||||
it("maps WorkOrderBoardRowDto fields into table columns", () => {
|
||||
const row = mapWorkOrderTableRow({
|
||||
id: 202,
|
||||
woNumber: "10000001235",
|
||||
workOrderType: 6,
|
||||
isPastDue: false,
|
||||
siteCode: "BK5",
|
||||
locationName: "Lobby",
|
||||
lifecycleStatus: 3,
|
||||
lifecycleStatusLabel: "Scheduled",
|
||||
dispatcherId: "guid-1",
|
||||
dispatcherName: "Alex Dispatcher",
|
||||
dueDate: "2026-07-18T00:00:00",
|
||||
scheduledDate: "2026-07-15T00:00:00",
|
||||
vendorId: 44,
|
||||
vendorName: "Acme HVAC LLC",
|
||||
techName: "Sam Tech",
|
||||
techPhone: "555-0200",
|
||||
apptTime: "07:00 – 09:00",
|
||||
pm: "PM Filter",
|
||||
serviceNotes: "service notes",
|
||||
extraServices: ["Filter change", "Coil clean"],
|
||||
docStatus: 1,
|
||||
dayGroup: "wednesday",
|
||||
flagColor: "#EF4444",
|
||||
});
|
||||
|
||||
expect(row.woNumber).toBe("10000001235");
|
||||
expect(row.site).toBe("BK5");
|
||||
expect(row.type).toBe("Reactive");
|
||||
expect(row.status).toBe("Scheduled");
|
||||
expect(row.scheduledOn).toBe("2026-07-15");
|
||||
expect(row.dueDate).toBe("2026-07-18");
|
||||
expect(row.company).toBe("Acme HVAC LLC");
|
||||
expect(row.tech).toBe("Sam Tech");
|
||||
expect(row.apptTime).toBe("07:00 – 09:00");
|
||||
expect(row.pm).toBe("PM Filter");
|
||||
expect(row.serviceNotes).toBe("service notes");
|
||||
expect(row.extraServices).toEqual(["Filter change", "Coil clean"]);
|
||||
expect(row.docStatus).toBe("Yes");
|
||||
expect(row.dayGroup).toBe("07/15");
|
||||
expect(row.flagColor).toBe("#EF4444");
|
||||
});
|
||||
|
||||
it("maps missing or empty flagColor as null", () => {
|
||||
expect(mapWorkOrderTableRow({ id: 1 }).flagColor).toBeNull();
|
||||
expect(mapWorkOrderTableRow({ id: 1, flagColor: null }).flagColor).toBeNull();
|
||||
expect(mapWorkOrderTableRow({ id: 1, flagColor: "" }).flagColor).toBeNull();
|
||||
expect(mapWorkOrderTableRow({ id: 1, FlagColor: "#3B82F6" }).flagColor).toBe("#3B82F6");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
160
src/test/domain/work-orders/utils/board-query-params.test.ts
Normal file
160
src/test/domain/work-orders/utils/board-query-params.test.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
|
||||
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
|
||||
import {
|
||||
BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||
buildBoardSearchParamsRecord,
|
||||
OVERDUE_TYPE_API,
|
||||
toBoardQueryParams,
|
||||
toBoardSearchQueryParams,
|
||||
} from "@/domain/work-orders/utils/board-query-params";
|
||||
|
||||
describe("toBoardQueryParams", () => {
|
||||
const baseBar = {
|
||||
weekMonday: "2026-07-13",
|
||||
dispatcherIds: new Set<string>(),
|
||||
type: "All" as const,
|
||||
};
|
||||
|
||||
it("omits search when shorter than 2 characters", () => {
|
||||
const params = toBoardQueryParams(baseBar, "B");
|
||||
expect(params.search).toBeUndefined();
|
||||
expect(params.weekStart).toBe("2026-07-13");
|
||||
expect(params.weekEnd).toBe("2026-07-17");
|
||||
});
|
||||
|
||||
it("includes trimmed search when length >= 2", () => {
|
||||
const params = toBoardQueryParams(baseBar, " BK5 ");
|
||||
expect(params.search).toBe("BK5");
|
||||
});
|
||||
|
||||
it("maps unassigned dispatcher to __unassigned__", () => {
|
||||
const params = toBoardQueryParams(
|
||||
{
|
||||
...baseBar,
|
||||
dispatcherIds: new Set([ASSIGNEE_FILTER_UNASSIGNED, "user-guid"]),
|
||||
},
|
||||
"",
|
||||
);
|
||||
expect(params.dispatchers).toEqual(["__unassigned__", "user-guid"]);
|
||||
});
|
||||
|
||||
it("maps PM type filter to API types", () => {
|
||||
const params = toBoardQueryParams({ ...baseBar, type: "PM" }, "");
|
||||
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]);
|
||||
});
|
||||
|
||||
it("maps Overdue bar filter to types=99", () => {
|
||||
const params = toBoardQueryParams({ ...baseBar, type: "Overdue" }, "");
|
||||
expect(params.types).toEqual([OVERDUE_TYPE_API]);
|
||||
expect(OVERDUE_TYPE_API).toBe(99);
|
||||
});
|
||||
|
||||
it("omits types when filter is All", () => {
|
||||
const params = toBoardQueryParams(baseBar, "");
|
||||
expect(params.types).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toBoardSearchQueryParams", () => {
|
||||
it("defaults page to 0 (0-based pagination)", () => {
|
||||
const params = toBoardSearchQueryParams({
|
||||
filters: { ...EMPTY_ADVANCED_FILTERS },
|
||||
});
|
||||
expect(params.page).toBe(0);
|
||||
expect(params.pageSize).toBe(BOARD_SEARCH_MAX_PAGE_SIZE);
|
||||
expect(params.datePreset).toBe("ThisWeek");
|
||||
});
|
||||
|
||||
it("maps date presets to PascalCase API values", () => {
|
||||
const params = toBoardSearchQueryParams({
|
||||
filters: { ...EMPTY_ADVANCED_FILTERS, dateRange: "last-3-months" },
|
||||
});
|
||||
expect(params.datePreset).toBe("Last3Months");
|
||||
});
|
||||
|
||||
it("includes dateFrom/dateTo only for Custom preset", () => {
|
||||
const custom = toBoardSearchQueryParams({
|
||||
filters: {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
dateRange: "custom",
|
||||
customFrom: "2026-06-01",
|
||||
customTo: "2026-06-30",
|
||||
},
|
||||
});
|
||||
expect(custom.datePreset).toBe("Custom");
|
||||
expect(custom.dateFrom).toBe("2026-06-01");
|
||||
expect(custom.dateTo).toBe("2026-06-30");
|
||||
|
||||
const thisWeek = toBoardSearchQueryParams({
|
||||
filters: {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
dateRange: "this-week",
|
||||
customFrom: "2026-06-01",
|
||||
customTo: "2026-06-30",
|
||||
},
|
||||
});
|
||||
expect(thisWeek.dateFrom).toBeUndefined();
|
||||
expect(thisWeek.dateTo).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps Overdue in advanced types to 99", () => {
|
||||
const params = toBoardSearchQueryParams({
|
||||
filters: {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
types: new Set(["PM", "Overdue"]),
|
||||
},
|
||||
});
|
||||
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM, OVERDUE_TYPE_API]);
|
||||
});
|
||||
|
||||
it("resolves vendor company names to vendorIds", () => {
|
||||
const params = toBoardSearchQueryParams({
|
||||
filters: {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
vendorTechs: new Set(["Acme HVAC", "Unknown"]),
|
||||
},
|
||||
vendorIdsByCompanyName: new Map([["Acme HVAC", 12]]),
|
||||
});
|
||||
expect(params.vendorIds).toEqual([12]);
|
||||
});
|
||||
|
||||
it("omits empty filter arrays", () => {
|
||||
const params = toBoardSearchQueryParams({
|
||||
filters: { ...EMPTY_ADVANCED_FILTERS },
|
||||
search: "x",
|
||||
});
|
||||
expect(params.search).toBeUndefined();
|
||||
expect(params.sites).toBeUndefined();
|
||||
expect(params.types).toBeUndefined();
|
||||
expect(params.dispatchers).toBeUndefined();
|
||||
expect(params.statuses).toBeUndefined();
|
||||
expect(params.pmTypes).toBeUndefined();
|
||||
expect(params.vendorIds).toBeUndefined();
|
||||
expect(params.docStatuses).toBeUndefined();
|
||||
});
|
||||
|
||||
it("caps pageSize at BOARD_SEARCH_MAX_PAGE_SIZE", () => {
|
||||
const params = toBoardSearchQueryParams({
|
||||
filters: { ...EMPTY_ADVANCED_FILTERS },
|
||||
pageSize: 500,
|
||||
});
|
||||
expect(params.pageSize).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBoardSearchParamsRecord", () => {
|
||||
it("keeps arrays as repeated-key friendly values (not types[])", () => {
|
||||
const record = buildBoardSearchParamsRecord({
|
||||
weekStart: "2026-07-13",
|
||||
types: [2, 6],
|
||||
dispatchers: ["guid-a", "__unassigned__"],
|
||||
search: "",
|
||||
});
|
||||
expect(record.types).toEqual([2, 6]);
|
||||
expect(record.dispatchers).toEqual(["guid-a", "__unassigned__"]);
|
||||
expect(record.search).toBeUndefined();
|
||||
expect(Object.keys(record)).not.toContain("types[]");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyDayRowOrder,
|
||||
applyRowOrderByDay,
|
||||
boardRowOrderStorageKey,
|
||||
loadBoardRowOrder,
|
||||
mergeDayOrder,
|
||||
removeIdFromRowOrder,
|
||||
reorderIdsInList,
|
||||
saveBoardRowOrder,
|
||||
} from "@/domain/work-orders/utils/board-row-order-storage";
|
||||
|
||||
describe("applyDayRowOrder", () => {
|
||||
const items = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
||||
|
||||
it("returns items unchanged when orderedIds is empty", () => {
|
||||
expect(applyDayRowOrder(items, undefined)).toEqual(items);
|
||||
expect(applyDayRowOrder(items, [])).toEqual(items);
|
||||
});
|
||||
|
||||
it("orders known ids and appends unknowns at the end", () => {
|
||||
expect(applyDayRowOrder(items, ["c", "a"])).toEqual([{ id: "c" }, { id: "a" }, { id: "b" }]);
|
||||
});
|
||||
|
||||
it("ignores stored ids that are not present", () => {
|
||||
expect(applyDayRowOrder(items, ["x", "b", "a"])).toEqual([
|
||||
{ id: "b" },
|
||||
{ id: "a" },
|
||||
{ id: "c" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyRowOrderByDay", () => {
|
||||
it("reorders within each dayGroup without mixing days", () => {
|
||||
const items = [
|
||||
{ id: "1", dayGroup: "07/14" },
|
||||
{ id: "2", dayGroup: "07/14" },
|
||||
{ id: "3", dayGroup: "07/15" },
|
||||
{ id: "4", dayGroup: "07/15" },
|
||||
];
|
||||
const result = applyRowOrderByDay(items, {
|
||||
"07/14": ["2", "1"],
|
||||
"07/15": ["4", "3"],
|
||||
});
|
||||
expect(result.map((r) => r.id)).toEqual(["2", "1", "4", "3"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reorderIdsInList", () => {
|
||||
it("moves drag id to over index", () => {
|
||||
expect(reorderIdsInList(["a", "b", "c"], "a", "c")).toEqual(["b", "c", "a"]);
|
||||
expect(reorderIdsInList(["a", "b", "c"], "c", "a")).toEqual(["c", "a", "b"]);
|
||||
});
|
||||
|
||||
it("returns null when ids are missing or identical", () => {
|
||||
expect(reorderIdsInList(["a", "b"], "a", "a")).toBeNull();
|
||||
expect(reorderIdsInList(["a", "b"], "x", "a")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeDayOrder", () => {
|
||||
it("keeps hidden ids after the visible block", () => {
|
||||
expect(mergeDayOrder(["a", "b", "c", "d"], ["c", "a"])).toEqual(["c", "a", "b", "d"]);
|
||||
});
|
||||
|
||||
it("returns visible order when there is no previous list", () => {
|
||||
expect(mergeDayOrder(undefined, ["b", "a"])).toEqual(["b", "a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeIdFromRowOrder", () => {
|
||||
it("removes the id from every day bucket and drops empty days", () => {
|
||||
const next = removeIdFromRowOrder(
|
||||
{ "07/14": ["1", "2"], "07/15": ["1"], __unscheduled__: ["3"] },
|
||||
"1",
|
||||
);
|
||||
expect(next).toEqual({ "07/14": ["2"], __unscheduled__: ["3"] });
|
||||
});
|
||||
|
||||
it("returns the same object when id is absent", () => {
|
||||
const prev = { "07/14": ["1"] };
|
||||
expect(removeIdFromRowOrder(prev, "9")).toBe(prev);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadBoardRowOrder / saveBoardRowOrder", () => {
|
||||
const week = "2026-07-13";
|
||||
const key = boardRowOrderStorageKey(week);
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem(key);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("round-trips a valid order map", () => {
|
||||
const order = { "07/14": ["1", "2"], __unscheduled__: ["9"] };
|
||||
saveBoardRowOrder(week, order);
|
||||
expect(loadBoardRowOrder(week)).toEqual(order);
|
||||
});
|
||||
|
||||
it("returns empty object for invalid JSON payload", () => {
|
||||
localStorage.setItem(key, "{not-json");
|
||||
expect(loadBoardRowOrder(week)).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for malformed shape", () => {
|
||||
localStorage.setItem(key, JSON.stringify({ "07/14": [1, 2] }));
|
||||
expect(loadBoardRowOrder(week)).toEqual({});
|
||||
});
|
||||
|
||||
it("swallows write failures", () => {
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("quota");
|
||||
});
|
||||
expect(() => saveBoardRowOrder(week, { "07/14": ["1"] })).not.toThrow();
|
||||
});
|
||||
});
|
||||
35
src/test/domain/work-orders/utils/comment-ownership.test.ts
Normal file
35
src/test/domain/work-orders/utils/comment-ownership.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkOrderComment } from "@/domain/work-orders/types/work-order-detail";
|
||||
import { isOwnWorkOrderComment } from "@/domain/work-orders/utils/comment-ownership";
|
||||
|
||||
function comment(partial: Partial<WorkOrderComment> = {}): WorkOrderComment {
|
||||
return {
|
||||
id: 1,
|
||||
createdDate: "2026-06-01T12:00:00Z",
|
||||
commenttext: "Hello",
|
||||
commentType: "internal",
|
||||
userName: "Sarah Chen",
|
||||
authorId: "user-1",
|
||||
commenter: null,
|
||||
recordType: "WorkOrder",
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isOwnWorkOrderComment", () => {
|
||||
it("returns true when authorId matches current user id", () => {
|
||||
expect(isOwnWorkOrderComment(comment({ authorId: "user-1" }), "user-1")).toBe(true);
|
||||
expect(isOwnWorkOrderComment(comment({ authorId: "42" }), 42)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when author or current user is missing", () => {
|
||||
expect(isOwnWorkOrderComment(comment({ authorId: null }), "user-1")).toBe(false);
|
||||
expect(isOwnWorkOrderComment(comment({ authorId: "" }), "user-1")).toBe(false);
|
||||
expect(isOwnWorkOrderComment(comment(), null)).toBe(false);
|
||||
expect(isOwnWorkOrderComment(comment(), undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when ids differ", () => {
|
||||
expect(isOwnWorkOrderComment(comment({ authorId: "user-1" }), "user-2")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { formatBoardAuditAction } from "@/domain/work-orders/utils/format-board-audit-action";
|
||||
|
||||
describe("formatBoardAuditAction", () => {
|
||||
it("keeps human sentences from action as-is", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: 'Changed status to "Incomplete"',
|
||||
fieldName: "",
|
||||
oldValue: "",
|
||||
newValue: "",
|
||||
}),
|
||||
).toBe('Changed status to "Incomplete"');
|
||||
});
|
||||
|
||||
it("formats lifecycle status from field/newValue", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "StatusChanged",
|
||||
fieldName: "LifecycleStatus",
|
||||
oldValue: "Scheduled",
|
||||
newValue: "Incomplete",
|
||||
}),
|
||||
).toBe('Changed status to "Incomplete"');
|
||||
});
|
||||
|
||||
it("formats WeekRolled / CarriedOver with increment", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "WeekRolled",
|
||||
fieldName: "CarriedOver",
|
||||
oldValue: "0",
|
||||
newValue: "1",
|
||||
}),
|
||||
).toBe("Carried over to the next week (increment #1)");
|
||||
});
|
||||
|
||||
it("formats schedule changes", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "Rescheduled",
|
||||
fieldName: "ScheduledDate",
|
||||
oldValue: "2026-07-10",
|
||||
newValue: "2026-07-16",
|
||||
}),
|
||||
).toBe('Changed schedule to "07/16/2026"');
|
||||
});
|
||||
|
||||
it("formats appt time ISO range as mm/dd/yyyy", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "ApptTimeChanged",
|
||||
fieldName: "ApptTime",
|
||||
oldValue: "",
|
||||
newValue: "2026-07-13T05:30:00.0000000|2026-07-13T07:00:00.0000000",
|
||||
}),
|
||||
).toBe('Changed appt time to "07/13/2026"');
|
||||
});
|
||||
|
||||
it("formats past due system message", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "MarkedPastDue",
|
||||
fieldName: "IsPastDue",
|
||||
oldValue: "false",
|
||||
newValue: "true",
|
||||
}),
|
||||
).toBe("WO marked as Past Due. Scheduled date has passed without completion.");
|
||||
});
|
||||
|
||||
it("falls back to from → to for unknown fields", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "FieldPatched",
|
||||
fieldName: "VendorNte",
|
||||
oldValue: "100",
|
||||
newValue: "250",
|
||||
}),
|
||||
).toBe('Changed vendor nte from "100" to "250"');
|
||||
});
|
||||
|
||||
it("formats JSON array values as comma-separated quoted items", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "FieldPatched",
|
||||
fieldName: "ExtraServices",
|
||||
oldValue: "",
|
||||
newValue: '["Other","Installation","Preventive Maintenance"]',
|
||||
}),
|
||||
).toBe('Changed extra services to "Other", "Installation", "Preventive Maintenance"');
|
||||
});
|
||||
|
||||
it("falls back to Updated work order when empty", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "",
|
||||
fieldName: "Something",
|
||||
oldValue: "",
|
||||
newValue: "",
|
||||
}),
|
||||
).toBe("Updated work order");
|
||||
});
|
||||
});
|
||||
28
src/test/domain/work-orders/utils/service-notes-ui.test.ts
Normal file
28
src/test/domain/work-orders/utils/service-notes-ui.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasServiceNotesDetails,
|
||||
truncateServiceNotesPreview,
|
||||
} from "@/domain/work-orders/utils/service-notes-ui";
|
||||
|
||||
describe("hasServiceNotesDetails", () => {
|
||||
it("is false when empty", () => {
|
||||
expect(hasServiceNotesDetails("", [])).toBe(false);
|
||||
expect(hasServiceNotesDetails(null, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when notes or extras exist", () => {
|
||||
expect(hasServiceNotesDetails("service notes", [])).toBe(true);
|
||||
expect(hasServiceNotesDetails("", ["Filter change"])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateServiceNotesPreview", () => {
|
||||
it("keeps short notes", () => {
|
||||
expect(truncateServiceNotesPreview("service notes")).toBe("service notes");
|
||||
});
|
||||
|
||||
it("truncates long notes with ellipsis", () => {
|
||||
const long = "a".repeat(40);
|
||||
expect(truncateServiceNotesPreview(long)).toBe(`${"a".repeat(30)}…`);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify-cross-week";
|
||||
|
||||
describe("shouldNotifyCrossWeek", () => {
|
||||
const activeMonday = "2026-07-13"; // Mon Jul 13 – Fri Jul 17
|
||||
|
||||
it("returns false when date is in the active week", () => {
|
||||
expect(shouldNotifyCrossWeek("2026-07-15", activeMonday)).toBe(false);
|
||||
expect(shouldNotifyCrossWeek("2026-07-13", activeMonday)).toBe(false);
|
||||
expect(shouldNotifyCrossWeek("2026-07-17", activeMonday)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when date is in another week", () => {
|
||||
expect(shouldNotifyCrossWeek("2026-07-20", activeMonday)).toBe(true);
|
||||
expect(shouldNotifyCrossWeek("2026-07-06", activeMonday)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for empty inputs", () => {
|
||||
expect(shouldNotifyCrossWeek("", activeMonday)).toBe(false);
|
||||
expect(shouldNotifyCrossWeek("2026-07-20", "")).toBe(false);
|
||||
});
|
||||
});
|
||||
96
src/test/domain/work-orders/utils/slide-over-draft.test.ts
Normal file
96
src/test/domain/work-orders/utils/slide-over-draft.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildSlideOverPatch,
|
||||
isSlideOverDraftDirty,
|
||||
SLIDE_OVER_EDIT_KEYS,
|
||||
} from "@/domain/work-orders/utils/slide-over-draft";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
id: 1,
|
||||
woNumber: "1001",
|
||||
site: "SITE-A",
|
||||
type: "PM",
|
||||
dispatcherId: "10",
|
||||
dispatcherName: "Alex",
|
||||
scheduledOn: "2026-07-16",
|
||||
targetWeek: "",
|
||||
dayGroup: "2026-07-16",
|
||||
dayLabel: "Thu",
|
||||
location: "Main",
|
||||
locationId: 5,
|
||||
pm: "Plumbing",
|
||||
serviceNotes: "",
|
||||
extraServices: [],
|
||||
vendorId: "v1",
|
||||
company: "Acme",
|
||||
tech: "Sam",
|
||||
techPhone: "",
|
||||
apptStart: "",
|
||||
apptEnd: "",
|
||||
apptTime: "09:00",
|
||||
status: "Incomplete",
|
||||
docStatus: "",
|
||||
pocName: "",
|
||||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
dueDate: "2026-07-20",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
isPastDue: false,
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 0,
|
||||
title: "",
|
||||
priority: "",
|
||||
primaryDispatchId: null,
|
||||
rowVersion: "1",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("slide-over-draft", () => {
|
||||
it("exposes only board-persistable edit keys", () => {
|
||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("woNumber");
|
||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("status");
|
||||
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("pocName");
|
||||
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId");
|
||||
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("techPhone");
|
||||
});
|
||||
|
||||
it("ignores POC-only edits for dirty state", () => {
|
||||
const row = baseRow();
|
||||
const draft = baseRow({ pocName: "Jordan", pocPhone: "555-0100" });
|
||||
expect(isSlideOverDraftDirty(draft, row)).toBe(false);
|
||||
expect(buildSlideOverPatch(draft, row)).toEqual({});
|
||||
});
|
||||
|
||||
it("detects dirty draft when a tracked field changes", () => {
|
||||
const row = baseRow();
|
||||
const draft = baseRow({ scheduledOn: "2026-07-17" });
|
||||
expect(isSlideOverDraftDirty(draft, row)).toBe(true);
|
||||
});
|
||||
|
||||
it("is not dirty when values match", () => {
|
||||
const row = baseRow();
|
||||
expect(isSlideOverDraftDirty(row, row)).toBe(false);
|
||||
});
|
||||
|
||||
it("builds a patch with only changed fields", () => {
|
||||
const row = baseRow();
|
||||
const draft = baseRow({
|
||||
woNumber: "1002",
|
||||
status: "Completed",
|
||||
extraServices: ["HVAC"],
|
||||
});
|
||||
expect(buildSlideOverPatch(draft, row)).toEqual({
|
||||
woNumber: "1002",
|
||||
status: "Completed",
|
||||
extraServices: ["HVAC"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import { mondayOf, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
|
|
@ -24,6 +25,8 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
location: "Main St",
|
||||
locationId: "10",
|
||||
pm: "HVAC",
|
||||
serviceNotes: "",
|
||||
extraServices: [],
|
||||
company: "Acme",
|
||||
tech: "Bob",
|
||||
techPhone: "",
|
||||
|
|
@ -45,6 +48,11 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
|||
mediaCount: 0,
|
||||
title: "Test WO",
|
||||
priority: "Sev 3",
|
||||
vendorId: "",
|
||||
primaryDispatchId: null,
|
||||
rowVersion: "AQAAAAAAAAA=",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -92,7 +100,13 @@ describe("buildDayGroups", () => {
|
|||
|
||||
describe("matchesAdvancedFilters", () => {
|
||||
it("filters by site and type", () => {
|
||||
const row = makeRow({ site: "SITE01", type: "PM" });
|
||||
const weekMonday = mondayOf(todayIso());
|
||||
const row = makeRow({
|
||||
site: "SITE01",
|
||||
type: "PM",
|
||||
scheduledOn: weekMonday,
|
||||
...deriveDayFields(weekMonday),
|
||||
});
|
||||
const filters = {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
dateRange: "custom" as const,
|
||||
|
|
|
|||
31
src/test/domain/work-orders/utils/wizard-date-utils.test.ts
Normal file
31
src/test/domain/work-orders/utils/wizard-date-utils.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
fmtDateRange,
|
||||
fmtShortDate,
|
||||
rangeDayCount,
|
||||
} from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
describe("fmtShortDate", () => {
|
||||
it("formats ISO dates as Mon D", () => {
|
||||
expect(fmtShortDate("2026-06-08")).toBe("Jun 8");
|
||||
});
|
||||
|
||||
it("returns em dash for invalid input", () => {
|
||||
expect(fmtShortDate("")).toBe("—");
|
||||
expect(fmtShortDate("nope")).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fmtDateRange", () => {
|
||||
it("joins start and end with en dash", () => {
|
||||
expect(fmtDateRange("2026-06-08", "2026-06-12")).toBe("Jun 8 – Jun 12");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rangeDayCount", () => {
|
||||
it("counts inclusive days order-independently", () => {
|
||||
expect(rangeDayCount("2026-06-08", "2026-06-12")).toBe(5);
|
||||
expect(rangeDayCount("2026-06-12", "2026-06-08")).toBe(5);
|
||||
expect(rangeDayCount("2026-06-08", "2026-06-08")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canDeleteWorkOrderMedia,
|
||||
isWorkOrderDocumentsReadOnly,
|
||||
isWorkOrderFileTooLarge,
|
||||
MAX_WORK_ORDER_MEDIA_BYTES,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
describe("work-order-media-limits", () => {
|
||||
it("treats Canceled and Completed as read-only", () => {
|
||||
expect(isWorkOrderDocumentsReadOnly("Canceled")).toBe(true);
|
||||
expect(isWorkOrderDocumentsReadOnly("Completed")).toBe(true);
|
||||
expect(isWorkOrderDocumentsReadOnly("Scheduled")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows delete only for non-legacy positive ids", () => {
|
||||
expect(canDeleteWorkOrderMedia({ id: "12", isLegacy: false })).toBe(true);
|
||||
expect(canDeleteWorkOrderMedia({ id: -1, isLegacy: true })).toBe(false);
|
||||
expect(canDeleteWorkOrderMedia({ id: "0" })).toBe(false);
|
||||
});
|
||||
|
||||
it("flags files over 30 MB", () => {
|
||||
expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES + 1 } as File)).toBe(true);
|
||||
expect(isWorkOrderFileTooLarge({ size: 10 } as File)).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue