feat(work-orders): add per-WO Aveta indicators and advanced filter

Dispatchers can flag Aveta on Service and Notes and filter those rows
without a site-level attribute.
This commit is contained in:
Arthur Bassi 2026-08-24 15:16:51 -03:00
parent cd092b3137
commit 7a30335ab8
59 changed files with 820 additions and 85 deletions

View file

@ -99,6 +99,25 @@ test.describe("Work Orders advanced filters", () => {
await expect(page.getByText("No work orders match these filters")).toBeVisible();
});
test("sends avetaOnly on board/search and shows empty copy", async ({ page }) => {
const searchUrls: string[] = [];
await signInAndOpenWorkOrders(page);
await mockWorkOrderApis(page, searchUrls);
await page.goto("/workorders");
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
timeout: 15_000,
});
const sheet = await applyAdvancedFilters(page);
await sheet.getByRole("button", { name: /Aveta Compliance/i }).click();
await sheet.getByRole("checkbox", { name: /Aveta required only/i }).click();
await sheet.getByRole("button", { name: /apply filters/i }).click();
await expect.poll(() => searchUrls.some((url) => url.includes("avetaOnly=true"))).toBe(true);
await expect(page.getByText("No work orders match these filters")).toBeVisible();
});
test("sends combined advanced-filter facets on the same board/search request", async ({
page,
}) => {

View file

@ -1,5 +1,7 @@
import { CircularProgress } from "@mui/material";
import { X } from "lucide-react";
import { AvetaBadge } from "@/app/(protected)/workorders/_components/list/table/cells/aveta-badge";
import { RescheduleCounterPill } from "@/app/(protected)/workorders/_components/list/table/cells/wo-counter-pills";
import { WoTableStatusLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label";
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
import { Text } from "@/components/ui/text";
@ -34,6 +36,8 @@ export function SlideOverHeader({
>
#{row.woNumber || "—"}
</Text>
<RescheduleCounterPill count={row.rescheduleCount} originalDate={row.originalDate} />
{Boolean(row.avetaRequired) && <AvetaBadge size={13} />}
{isCancelled && (
<span

View file

@ -5,12 +5,14 @@ import {
Film,
Image as ImageIcon,
Paperclip,
ShieldCheck,
Upload,
X,
} from "lucide-react";
import { toast } from "react-toastify";
import { Text } from "@/components/ui/text";
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
import { resolveExtraDocCategory } from "@/domain/work-orders/utils/extra-docs-aveta";
import {
canDeleteWorkOrderMedia,
formatMediaSizeLimit,
@ -31,14 +33,73 @@ type SlideOverExtrasTabProps = {
uploads: MediaFile[];
isReadOnly: boolean;
pending?: boolean;
avetaRequired?: boolean;
onUpload: (files: File[]) => void;
onRemove: (id: string) => void;
};
function ExtraDocRow({
file,
index,
uploads,
avetaRequired,
isReadOnly,
pending,
onRemove,
}: {
file: MediaFile;
index: number;
uploads: MediaFile[];
avetaRequired: boolean;
isReadOnly: boolean;
pending: boolean;
onRemove: (id: string) => void;
}) {
const category = resolveExtraDocCategory(file, index, avetaRequired, uploads);
const isAveta = category === "Aveta";
const ItemIcon = isAveta ? ShieldCheck : FILETYPE_ICON[file.fileType];
const showDelete = !isReadOnly && canDeleteWorkOrderMedia(file);
return (
<div className="flex items-center gap-2 py-1">
<ItemIcon
size={12}
className="shrink-0"
style={{ color: isAveta ? "var(--warning)" : "var(--color-text-muted)" }}
/>
<a
href={file.url}
target="_blank"
rel="noreferrer"
className="min-w-0 flex-1 truncate hover:underline"
style={{ fontSize: 12, color: "var(--foreground)" }}
>
{file.name}
</a>
{isAveta && (
<span style={{ fontSize: 10, fontWeight: 600, color: "var(--warning)" }}>Aveta</span>
)}
{showDelete && (
<button
type="button"
title="Remove"
disabled={pending}
onClick={() => onRemove(file.id)}
className="rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-50"
style={{ color: "var(--color-text-muted)" }}
>
<X size={12} />
</button>
)}
</div>
);
}
export function SlideOverExtrasTab({
uploads,
isReadOnly,
pending = false,
avetaRequired = false,
onUpload,
onRemove,
}: SlideOverExtrasTabProps) {
@ -70,6 +131,15 @@ export function SlideOverExtrasTab({
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
Extra Documents
</span>
{Boolean(avetaRequired) && (
<span
className="inline-flex items-center gap-1"
style={{ fontSize: 11, fontWeight: 500, color: "var(--warning)" }}
>
<ShieldCheck size={12} />
Aveta
</span>
)}
</div>
{!isReadOnly && (
@ -122,40 +192,18 @@ export function SlideOverExtrasTab({
</Text>
) : (
<div className="space-y-1">
{uploads.map((file) => {
const ItemIcon = FILETYPE_ICON[file.fileType];
const showDelete = !isReadOnly && canDeleteWorkOrderMedia(file);
return (
<div key={file.id} className="flex items-center gap-2 py-1">
<ItemIcon
size={12}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
<a
href={file.url}
target="_blank"
rel="noreferrer"
className="min-w-0 flex-1 truncate hover:underline"
style={{ fontSize: 12, color: "var(--foreground)" }}
>
{file.name}
</a>
{showDelete && (
<button
type="button"
title="Remove"
disabled={pending}
onClick={() => onRemove(file.id)}
className="rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-50"
style={{ color: "var(--color-text-muted)" }}
>
<X size={12} />
</button>
)}
</div>
);
})}
{uploads.map((file, index) => (
<ExtraDocRow
key={file.id}
file={file}
index={index}
uploads={uploads}
avetaRequired={avetaRequired}
isReadOnly={isReadOnly}
pending={pending}
onRemove={onRemove}
/>
))}
</div>
)}
</div>

View file

@ -2,6 +2,7 @@ import { SiteCell } from "@/app/(protected)/workorders/_components/list/table/ce
import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
import { VendorCell } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-cell";
import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
import { AvetaBadge } from "@/app/(protected)/workorders/_components/list/table/cells/aveta-badge";
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
import type {
@ -100,6 +101,7 @@ export function SlideOverInfoTabDisplayView({
pmTypes={pmTypes}
serviceNotes={source.serviceNotes}
extraServices={source.extraServices}
avetaRequired={source.avetaRequired}
woNumber={source.woNumber}
viewOnly
onSave={() => undefined}
@ -108,6 +110,7 @@ export function SlideOverInfoTabDisplayView({
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
<PmTypeIcon pmType={source.pm} size={14} />
{source.pm}
{Boolean(source.avetaRequired) && <AvetaBadge size={11} />}
</span>
) : (
<span style={{ color: "var(--color-text-muted)" }}>—</span>

View file

@ -1,6 +1,7 @@
import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell";
import { ApptTimeCell } from "@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell";
import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell";
import { DueDateCell } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-cell";
import { EditableWONumberCell } from "@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell";
import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
import { SiteCell } from "@/app/(protected)/workorders/_components/list/table/cells/site-cell";
@ -48,6 +49,7 @@ export function SlideOverInfoTabEditView({
value={draft.woNumber}
rescheduleCount={draft.rescheduleCount}
carriedOver={draft.carriedOver}
originalDate={draft.originalDate}
currentId={draft.id}
allOrders={allOrders}
onSave={(woNumber) => onDraftChange({ woNumber })}
@ -113,7 +115,11 @@ export function SlideOverInfoTabEditView({
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Due Date">
<DateCell value={draft.dueDate} onSave={(dueDate) => onDraftChange({ dueDate })} />
<DueDateCell
type={draft.type}
value={draft.dueDate}
onSave={(dueDate) => onDraftChange({ dueDate })}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Appt Time">
<ApptTimeCell
@ -127,12 +133,15 @@ export function SlideOverInfoTabEditView({
pmTypes={pmTypes}
serviceNotes={draft.serviceNotes}
extraServices={draft.extraServices}
avetaRequired={draft.avetaRequired}
woNumber={draft.woNumber}
onSave={(pm) => onDraftChange({ pm })}
onSaveDetails={(details) =>
onDraftChange({
pm: details.pm,
serviceNotes: details.serviceNotes,
extraServices: details.extraServices,
avetaRequired: details.avetaRequired,
})
}
/>

View file

@ -65,6 +65,7 @@ export function useSlideOverMediaState({
setMediaBatchPending(true);
try {
for (const file of files) {
// Board purpose is Extra only; Aveta is a UI overlay when avetaRequired.
await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
}
} finally {

View file

@ -146,6 +146,7 @@ export function WorkOrderSlideOverTabs({
>
<SlideOverExtrasTab
uploads={state.extrasUploads}
avetaRequired={Boolean(infoSource?.avetaRequired ?? activeDraft?.avetaRequired)}
isReadOnly={state.isExtrasReadOnly}
pending={state.mediaPending}
onUpload={(files) => {

View file

@ -13,7 +13,11 @@ import {
import { AdvDocPill, AdvStatusPill } from "./adv-filter-pill";
import { AdvDateRangeSection } from "./adv-date-range-section";
import { AdvMultiSelect } from "./adv-multi-select";
import { AdvFlagInternalUpliftSections, AdvIndicatorsSection } from "./adv-parity-filter-sections";
import {
AdvAvetaSection,
AdvFlagInternalUpliftSections,
AdvIndicatorsSection,
} from "./adv-parity-filter-sections";
import { AdvSection } from "./adv-section";
import { AdvSeverityList } from "./adv-severity-list";
import { AdvTypeGrid } from "./adv-type-grid";
@ -127,6 +131,8 @@ export function AdvancedSearchFilterSections({
<AdvIndicatorsSection draft={draft} setDraft={setDraft} />
<AdvAvetaSection draft={draft} setDraft={setDraft} />
<AdvSection icon={Flame} label="Service" count={draft.pmTypes.size}>
<AdvMultiSelect
options={pmTypes.map((pmType) => ({ value: pmType, label: pmType }))}

View file

@ -1,5 +1,14 @@
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { ArrowRight, Check, DollarSign, Flag, Hash, Plus, RefreshCw } from "lucide-react";
import {
ArrowRight,
Check,
DollarSign,
Flag,
Hash,
Plus,
RefreshCw,
ShieldCheck,
} from "lucide-react";
import { Checkbox } from "@mui/material";
import { AdvDocPill } from "./adv-filter-pill";
import { AdvMultiSelect } from "./adv-multi-select";
@ -115,6 +124,26 @@ export function AdvIndicatorsSection({ draft, setDraft }: DraftProps) {
);
}
export function AdvAvetaSection({ draft, setDraft }: DraftProps) {
return (
<AdvSection icon={ShieldCheck} label="Aveta Compliance" count={draft.avetaOnly ? 1 : 0}>
<label className="flex cursor-pointer items-center gap-1.5 select-none">
<Checkbox
size="small"
checked={draft.avetaOnly}
onChange={() => setDraft((prev) => ({ ...prev, avetaOnly: !prev.avetaOnly }))}
sx={{
padding: 0,
color: "var(--color-border-hover)",
"&.Mui-checked": { color: "var(--warning)" },
}}
/>
<span className="text-sm text-muted-foreground">Aveta required only</span>
</label>
</AdvSection>
);
}
export function AdvFlagInternalUpliftSections({
draft,
setDraft,

View file

@ -28,6 +28,7 @@ export function buildInlineDraftCreateInput(
dueDate: draft.dueDate,
pm: draft.pm,
extraServices: draft.extraServices ?? [],
avetaRequired: Boolean(draft.avetaRequired),
pmNote: draft.serviceNotes ?? "",
vendorId: draft.vendorId || "",
vendorName: draft.company,

View file

@ -0,0 +1,17 @@
import { ShieldCheck } from "lucide-react";
import { Tooltip } from "@mui/material";
type AvetaBadgeProps = {
size?: number;
};
/** Per-WO Aveta shield — never a site-level flag. Color is paired with the icon. */
export function AvetaBadge({ size = 11 }: AvetaBadgeProps) {
return (
<Tooltip title="This work order requires Aveta compliance documentation" arrow placement="top">
<span className="inline-flex shrink-0" aria-label="Aveta required">
<ShieldCheck size={size} style={{ color: "var(--warning)" }} />
</span>
</Tooltip>
);
}

View file

@ -0,0 +1,28 @@
type AvetaCheckboxProps = {
checked: boolean;
onChange: (value: boolean) => void;
disabled?: boolean;
};
export function AvetaCheckbox({ checked, onChange, disabled = false }: AvetaCheckboxProps) {
return (
<label className="flex cursor-pointer items-center gap-2 select-none">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
className="size-3.5 accent-[var(--warning)]"
/>
<span
style={{
fontSize: 12,
fontWeight: 500,
color: "var(--color-text-primary)",
}}
>
Requires Aveta documentation
</span>
</label>
);
}

View file

@ -43,6 +43,7 @@ export function DueDateCell({ type, value, q, highlight, onSave }: DueDateCellPr
}
: undefined
}
aria-label="Due Date"
aria-haspopup="dialog"
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">

View file

@ -5,12 +5,14 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
import { EditableWONumberInput } from "./editable-wo-number-input";
import { HighlightText } from "./highlight-text";
import { AddOnPill, CarriedOverCounterPill, RescheduleCounterPill } from "./wo-counter-pills";
type EditableWONumberCellProps = {
value: string;
rescheduleCount: number;
carriedOver?: number;
isAddOn?: boolean;
originalDate?: string;
q?: string;
currentId: string | number;
allOrders: WorkOrderTableRow[];
@ -19,42 +21,6 @@ type EditableWONumberCellProps = {
locked?: boolean;
};
function CounterPill({ label, count }: { label: string; count: number }) {
if (count < 2) return null;
return (
<span
title={label}
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
style={{
background: "var(--color-bg-muted)",
color: "var(--color-text-secondary)",
border: "1px solid var(--color-border)",
}}
>
{count}
</span>
);
}
function AddOnPill({ when }: { when: boolean }) {
if (!when) {
return null;
}
return (
<span
title="Add-On"
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
style={{
background: "var(--type-addon-bg)",
color: "var(--type-addon-text)",
}}
>
Add-On
</span>
);
}
async function resolveDuplicateWoNumber(
draft: string,
allOrders: WorkOrderTableRow[],
@ -70,6 +36,7 @@ export function EditableWONumberCell({
rescheduleCount,
carriedOver = 0,
isAddOn = false,
originalDate,
q,
currentId,
allOrders,
@ -150,8 +117,8 @@ export function EditableWONumberCell({
{value ? <HighlightText text={value} q={q} /> : "-"}
</span>
<AddOnPill when={isAddOn} />
<CounterPill label="Reschedules" count={rescheduleCount} />
<CounterPill label="Carried over" count={carriedOver} />
<RescheduleCounterPill count={rescheduleCount} originalDate={originalDate} />
<CarriedOverCounterPill count={carriedOver} />
</div>
);
}
@ -187,8 +154,8 @@ export function EditableWONumberCell({
</span>
)}
<AddOnPill when={isAddOn} />
<CounterPill label="Reschedules" count={rescheduleCount} />
<CounterPill label="Carried over" count={carriedOver} />
<RescheduleCounterPill count={rescheduleCount} originalDate={originalDate} />
<CarriedOverCounterPill count={carriedOver} />
</div>
);
}

View file

@ -6,6 +6,7 @@ import {
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 { AvetaBadge } from "@/app/(protected)/workorders/_components/list/table/cells/aveta-badge";
import { PmTypeDetailsTrigger } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger";
import { PmTypePickerPopover } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover";
import {
@ -20,6 +21,7 @@ type PMTypeCellProps = {
onSave: (v: string) => void;
serviceNotes?: string;
extraServices?: string[];
avetaRequired?: boolean;
woNumber?: string;
onSaveDetails?: (details: ServiceNotesDetails) => void;
viewOnly?: boolean;
@ -32,6 +34,7 @@ export function PMTypeCell({
onSave,
serviceNotes = "",
extraServices = [],
avetaRequired = false,
woNumber,
onSaveDetails,
viewOnly = false,
@ -45,7 +48,7 @@ export function PMTypeCell({
: pmTypes;
const extras = extraServices ?? [];
const hasDetails = hasServiceNotesDetails(serviceNotes, extras);
const hasDetails = hasServiceNotesDetails(serviceNotes, extras, avetaRequired);
const notePreview = serviceNotes?.trim() ? truncateServiceNotesPreview(serviceNotes) : "";
const closePopover = () => {
@ -61,6 +64,7 @@ export function PMTypeCell({
pm={value}
extraServices={extras}
serviceNotes={serviceNotes ?? ""}
avetaRequired={avetaRequired}
woNumber={woNumber}
pmTypes={pmTypes}
viewOnly={viewOnly}
@ -95,6 +99,7 @@ export function PMTypeCell({
) : (
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>—</span>
)}
{Boolean(avetaRequired) && <AvetaBadge size={11} />}
</button>
{modal}
</>
@ -109,6 +114,7 @@ export function PMTypeCell({
q={q}
notePreview={notePreview}
extrasCount={extras.length}
avetaRequired={avetaRequired}
onOpenDetails={() => setModalOpen(true)}
/>
{modal}

View file

@ -1,3 +1,4 @@
import { AvetaBadge } from "@/app/(protected)/workorders/_components/list/table/cells/aveta-badge";
import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text";
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
@ -6,6 +7,7 @@ type PmTypeDetailsTriggerProps = {
q?: string;
notePreview: string;
extrasCount: number;
avetaRequired?: boolean;
onOpenDetails: () => void;
};
@ -14,6 +16,7 @@ export function PmTypeDetailsTrigger({
q,
notePreview,
extrasCount,
avetaRequired = false,
onOpenDetails,
}: PmTypeDetailsTriggerProps) {
return (
@ -41,6 +44,7 @@ export function PmTypeDetailsTrigger({
>
{value ? <HighlightText text={value} q={q} /> : "Select …"}
</span>
{Boolean(avetaRequired) && <AvetaBadge size={11} />}
</span>
{notePreview ? (
<span

View file

@ -9,6 +9,7 @@ export type ServiceNotesDetails = {
pm: string;
extraServices: string[];
serviceNotes: string;
avetaRequired: boolean;
};
type ServiceNotesDialogProps = {
@ -17,6 +18,7 @@ type ServiceNotesDialogProps = {
pm: string;
extraServices: string[];
serviceNotes: string;
avetaRequired?: boolean;
woNumber?: string;
pmTypes: string[];
viewOnly?: boolean;
@ -29,6 +31,7 @@ export function ServiceNotesDialog({
pm,
extraServices,
serviceNotes,
avetaRequired = false,
woNumber,
pmTypes,
viewOnly = false,
@ -36,6 +39,7 @@ export function ServiceNotesDialog({
}: ServiceNotesDialogProps) {
const [sel, setSel] = useState<string[]>([]);
const [note, setNote] = useState("");
const [aveta, setAveta] = useState(false);
const [search, setSearch] = useState("");
useEffect(() => {
@ -44,8 +48,9 @@ export function ServiceNotesDialog({
}
setSel([pm, ...(extraServices ?? [])].filter((p, i, a) => !!p && a.indexOf(p) === i));
setNote(serviceNotes ?? "");
setAveta(Boolean(avetaRequired));
setSearch("");
}, [open, pm, extraServices, serviceNotes]);
}, [open, pm, extraServices, serviceNotes, avetaRequired]);
const toggle = (value: string) => {
setSel((cur) => (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value]));
@ -87,6 +92,8 @@ export function ServiceNotesDialog({
setNote={setNote}
search={search}
setSearch={setSearch}
avetaRequired={aveta}
onAvetaRequiredChange={setAveta}
/>
</fieldset>
<div className="mt-2 flex items-center justify-end gap-2">
@ -116,6 +123,7 @@ export function ServiceNotesDialog({
pm: sel[0] ?? "",
extraServices: sel.slice(1),
serviceNotes: note,
avetaRequired: aveta,
});
onOpenChange(false);
}}

View file

@ -0,0 +1,79 @@
import { RefreshCw } from "lucide-react";
import { Tooltip } from "@mui/material";
import { formatRescheduleHover } from "@/domain/work-orders/utils/reschedule-indicator";
const RESCHEDULE_PILL_STYLE = {
background: "#f5f3ff",
color: "#7c3aed",
border: "1px solid #ddd6fe",
} as const;
const MUTED_PILL_STYLE = {
background: "var(--color-bg-muted)",
color: "var(--color-text-secondary)",
border: "1px solid var(--color-border)",
} as const;
const ADDON_PILL_STYLE = {
background: "var(--type-addon-bg)",
color: "var(--type-addon-text)",
} as const;
export function RescheduleCounterPill({
count,
originalDate,
}: {
count: number;
originalDate?: string;
}) {
if (count < 2) {
return null;
}
const hover = formatRescheduleHover(count, originalDate);
return (
<Tooltip title={hover} arrow placement="top">
<span
aria-label={hover}
className="inline-flex h-[18px] items-center gap-0.5 rounded px-1.5 text-[10px] font-semibold"
style={RESCHEDULE_PILL_STYLE}
>
<RefreshCw size={10} className="shrink-0" />
{count}
</span>
</Tooltip>
);
}
export function CarriedOverCounterPill({ count }: { count: number }) {
if (count < 2) {
return null;
}
return (
<span
title="Carried over"
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
style={MUTED_PILL_STYLE}
>
{count}
</span>
);
}
export function AddOnPill({ when }: { when: boolean }) {
if (!when) {
return null;
}
return (
<span
title="Add-On"
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
style={ADDON_PILL_STYLE}
>
Add-On
</span>
);
}

View file

@ -178,6 +178,7 @@ export function WoTableInlineRowCells({
pmTypes={pmTypes}
serviceNotes={draft.serviceNotes}
extraServices={draft.extraServices}
avetaRequired={draft.avetaRequired}
woNumber={draft.woNumber}
onSave={(pm) => onPatch({ pm })}
onSaveDetails={(d) =>
@ -185,6 +186,7 @@ export function WoTableInlineRowCells({
pm: d.pm,
extraServices: d.extraServices,
serviceNotes: d.serviceNotes,
avetaRequired: d.avetaRequired,
})
}
/>

View file

@ -81,6 +81,7 @@ export function WoTableRowCells({
rescheduleCount={row.rescheduleCount}
carriedOver={row.carriedOver}
isAddOn={row.isAddOn}
originalDate={row.originalDate}
q={search}
currentId={row.id}
allOrders={allRows}

View file

@ -49,6 +49,7 @@ export function WoTableRowServiceCells({
q={search}
serviceNotes={row.serviceNotes}
extraServices={row.extraServices}
avetaRequired={row.avetaRequired}
woNumber={row.woNumber}
viewOnly={viewOnly}
onSave={(pm) => onPatchRow({ pm })}
@ -57,6 +58,7 @@ export function WoTableRowServiceCells({
pm: d.pm,
extraServices: d.extraServices,
serviceNotes: d.serviceNotes,
avetaRequired: d.avetaRequired,
})
}
/>

View file

@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { Check } from "lucide-react";
import { getPmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
import { AvetaCheckbox } from "@/app/(protected)/workorders/_components/list/table/cells/aveta-checkbox";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
@ -14,6 +15,8 @@ type WizardServiceNotesFieldsProps = {
setNote: (v: string) => void;
search: string;
setSearch: (v: string) => void;
avetaRequired?: boolean;
onAvetaRequiredChange?: (value: boolean) => void;
};
function SvcSectionLabel({ children }: { children: ReactNode }) {
@ -43,6 +46,8 @@ export function WizardServiceNotesFields({
setNote,
search,
setSearch,
avetaRequired = false,
onAvetaRequiredChange,
}: WizardServiceNotesFieldsProps) {
const filtered = search
? services.filter((s) => s.value.toLowerCase().includes(search.toLowerCase()))
@ -124,6 +129,11 @@ export function WizardServiceNotesFields({
)}
</div>
</div>
{onAvetaRequiredChange != null && (
<div style={{ marginTop: 16 }}>
<AvetaCheckbox checked={avetaRequired} onChange={onAvetaRequiredChange} />
</div>
)}
<div style={{ marginTop: 16 }}>
<SvcSectionLabel>Notes / Issue Detail</SvcSectionLabel>
<textarea

View file

@ -91,6 +91,8 @@ export function WizardStepLocationServiceSelect({
setNote={(v) => onUpdate("pmNote", v)}
search={svcSearch}
setSearch={onSvcSearchChange}
avetaRequired={draft.avetaRequired}
onAvetaRequiredChange={(value) => onUpdate("avetaRequired", value)}
/>
</div>
)}

View file

@ -63,4 +63,8 @@ export function assignWizardServicePocFields(
if (draft.severity != null) {
payload.severity = draft.severity;
}
if (draft.avetaRequired) {
payload.avetaRequired = true;
}
}

View file

@ -38,6 +38,11 @@ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
{ key: "pm", field: "pm", toValue: (value) => value as string },
{ key: "extraServices", field: "extraServices", toValue: (value) => JSON.stringify(value) },
{ key: "serviceNotes", field: "serviceNotes", toValue: (value) => value as string },
{
key: "avetaRequired",
field: "avetaRequired",
toValue: (value) => (value ? "true" : "false"),
},
{ key: "flagColor", field: "flagColor", toValue: (value) => (value as string | null) ?? "" },
{
key: "additionalContacts",

View file

@ -368,6 +368,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0,
carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0,
isAddOn: readIsAddOn(item),
avetaRequired: readBool(item, "avetaRequired", "AvetaRequired"),
originalWeek: readString(item, "originalWeek", "OriginalWeek"),
originalDate: toDateInputValue(readString(item, "originalDate", "OriginalDate")),
isPastDue: readBool(item, "isPastDue", "IsPastDue"),

View file

@ -34,6 +34,7 @@ export interface WorkOrderAdvancedFilters {
rescheduled: boolean;
carriedOver: boolean;
addOn: boolean;
avetaOnly: boolean;
pmTypes: Set<string>;
vendorTechs: Set<string>;
docs: Set<CompletionDocFilter>;
@ -55,6 +56,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
rescheduled: false,
carriedOver: false,
addOn: false,
avetaOnly: false,
pmTypes: new Set(),
vendorTechs: new Set(),
docs: new Set(),

View file

@ -50,6 +50,8 @@ export interface WorkOrderBoardDetailInfoApi {
pm?: string | null;
serviceNotes?: string | null;
extraServices?: string[] | null;
/** Per-WO Aveta compliance flag (Service & Notes). */
avetaRequired?: boolean | null;
docStatus?: number | null;
flagColor?: string | null;
primaryDispatchId?: number | null;

View file

@ -104,6 +104,8 @@ export interface WorkOrderBoardCreatePayload {
extraServices?: string[];
/** Issue detail from Service & Notes (`pmNote` in draft). */
serviceNotes?: string;
/** Per-WO Aveta compliance flag from Service & Notes. */
avetaRequired?: boolean;
locationId?: number;
pocContactId?: number;
pocName?: string;
@ -142,6 +144,7 @@ export type WorkOrderBoardEditableField =
| "completedDate"
| "pm"
| "serviceNotes"
| "avetaRequired"
| "extraServices"
| "flagColor"
| "additionalContacts"
@ -155,6 +158,11 @@ export interface WorkOrderBoardRowApi {
carriedOver?: number;
/** Backend-authoritative Add-On indicator (SH-184; recalculated on schedule). */
isAddOn?: boolean | null;
/** Per-WO Aveta compliance flag (Service & Notes). */
avetaRequired?: boolean | null;
/** Original schedule snapshot for Reschedule hover history. */
originalDate?: string | null;
originalWeek?: string | null;
workOrderType?: number | null;
isPastDue?: boolean;
siteCode?: string | null;
@ -277,6 +285,8 @@ export interface WorkOrderBoardSearchQueryParams {
rescheduled?: boolean;
carriedOver?: boolean;
addOn?: boolean;
/** Only WOs flagged `avetaRequired`. */
avetaOnly?: boolean;
flagColors?: string[];
internalOnly?: boolean;
hasUplift?: boolean;

View file

@ -1,7 +1,7 @@
export type FileCategory = "image" | "video" | "pdf" | "doc" | "other";
export type MediaCategory =
"Photo" | "Video" | "Receipt" | "Quote" | "Invoice" | "Report" | "Other";
"Photo" | "Video" | "Receipt" | "Quote" | "Invoice" | "Report" | "Aveta" | "Other";
export interface MediaFile {
id: string;
@ -26,6 +26,7 @@ export const MEDIA_CATEGORIES: MediaCategory[] = [
"Quote",
"Invoice",
"Report",
"Aveta",
"Other",
];

View file

@ -57,6 +57,8 @@ export interface WorkOrderTableRow {
* recalculated on schedule week moves / clear; FE only reflects API value.
*/
isAddOn: boolean;
/** Per-WO Aveta compliance flag from Service & Notes (not a site attribute). */
avetaRequired?: boolean;
originalWeek: string;
originalDate: string;
isPastDue: boolean;
@ -87,6 +89,7 @@ export interface WorkOrderTableDraft {
pm: string;
serviceNotes: string;
extraServices: string[];
avetaRequired?: boolean;
vendorId: string;
company: string;
tech: string;
@ -118,6 +121,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
pm: "",
serviceNotes: "",
extraServices: [],
avetaRequired: false,
vendorId: "",
company: "",
tech: "",

View file

@ -41,6 +41,8 @@ export interface WorkOrderWizardDraft {
extraServices: string[];
/** Free-text note attached to the service (Service & Notes). */
pmNote: string;
/** Per-WO Aveta compliance flag from Service & Notes. */
avetaRequired: boolean;
vendorId: string;
vendorName: string;
techPhone: string;
@ -82,6 +84,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
pm: "",
extraServices: [],
pmNote: "",
avetaRequired: false,
vendorId: "",
vendorName: "",
techPhone: "",

View file

@ -231,6 +231,14 @@ export function appendIndicatorChips(
clear: (current) => ({ ...current, addOn: false }),
});
}
if (filters.avetaOnly) {
chips.push({
key: "aveta",
group: "Aveta",
value: "Required",
clear: (current) => ({ ...current, avetaOnly: false }),
});
}
}
export function appendFlagAndInternalChips(

View file

@ -50,6 +50,9 @@ const matchesRescheduled: AdvancedFilterPredicate = (row, filters) =>
const matchesCarriedOver: AdvancedFilterPredicate = (row, filters) =>
!filters.carriedOver || row.carriedOver >= 2;
const matchesAvetaOnly: AdvancedFilterPredicate = (row, filters) =>
!filters.avetaOnly || row.avetaRequired === true;
const matchesInternalOnly: AdvancedFilterPredicate = (row, filters) =>
!filters.internalOnly || row.woNumber.toUpperCase().startsWith("SH");
@ -68,6 +71,7 @@ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [
matchesDoc,
matchesRescheduled,
matchesCarriedOver,
matchesAvetaOnly,
matchesInternalOnly,
matchesFlagColors,
];

View file

@ -45,6 +45,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool
filters.rescheduled,
filters.carriedOver,
filters.addOn,
filters.avetaOnly,
Boolean(filters.pmTypes?.size),
Boolean(filters.vendorTechs?.size),
Boolean(filters.docs?.size),

View file

@ -87,7 +87,7 @@ function tryPastDueRule({ fieldKey, actionKey }: AuditActionRuleContext): string
fieldKey.includes("pastdue") || actionKey.includes("pastdue") || actionKey === "markedpastdue";
if (!matches) return null;
return "WO marked as Past Due. Due date has passed without completion.";
return "WO marked as Past Due. Schedule On has passed without completion.";
}
function tryCreatedRule({ fieldKey, actionKey }: AuditActionRuleContext): string | null {

View file

@ -116,6 +116,7 @@ function mapSearchFacetParams(
| "rescheduled"
| "carriedOver"
| "addOn"
| "avetaOnly"
| "flagColors"
| "internalOnly"
| "hasUplift"
@ -126,6 +127,7 @@ function mapSearchFacetParams(
rescheduled: filters.rescheduled || undefined,
carriedOver: filters.carriedOver || undefined,
addOn: filters.addOn || undefined,
avetaOnly: filters.avetaOnly || undefined,
flagColors: filters.flagColors?.size ? [...filters.flagColors] : undefined,
internalOnly: filters.internalOnly || undefined,
hasUplift: filters.hasUplift || undefined,

View file

@ -0,0 +1,26 @@
import type { MediaCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
/** First Extra Docs file defaults to Aveta when the WO is flagged and none is Aveta yet.
* Board media purpose stays Extra (API enum Before/After/Extra/Completion). */
export function resolveExtraDocCategory(
file: MediaFile,
index: number,
avetaRequired: boolean,
files: MediaFile[],
): MediaCategory {
if (file.category === "Aveta") {
return "Aveta";
}
const hasExplicitAveta = files.some((item) => item.category === "Aveta");
if (avetaRequired && index === 0 && !hasExplicitAveta) {
return "Aveta";
}
return file.category;
}
export function shouldDefaultFirstExtraToAveta(
avetaRequired: boolean,
existing: MediaFile[],
): boolean {
return avetaRequired && !existing.some((item) => item.category === "Aveta");
}

View file

@ -0,0 +1,16 @@
import { fmtDate } from "@/domain/work-orders/utils/wizard-date-utils";
export function formatRescheduleCountLabel(count: number): string {
return count === 1 ? "1 time" : `${count} times`;
}
/** Hover copy for the Reschedule pill (count ≥ 2). Uses originalDate when the API sends it. */
export function formatRescheduleHover(count: number, originalDate?: string): string {
const times = formatRescheduleCountLabel(count);
const base = `Rescheduled ${times} — this WO has had its date changed ${times}.`;
const formatted = originalDate ? fmtDate(originalDate) : "";
if (!formatted) {
return base;
}
return `${base} Original date: ${formatted}.`;
}

View file

@ -1,9 +1,12 @@
/** Design: cell shows Service & Notes layout when note or extras exist. */
/** Design: cell shows Service & Notes layout when note, extras, or Aveta exist. */
export function hasServiceNotesDetails(
serviceNotes?: string | null,
extraServices?: string[] | null,
avetaRequired?: boolean | null,
): boolean {
return Boolean(serviceNotes?.trim()) || (extraServices?.length ?? 0) > 0;
return (
Boolean(serviceNotes?.trim()) || (extraServices?.length ?? 0) > 0 || Boolean(avetaRequired)
);
}
/** Truncate note for the board cell subtitle (~30 chars, design). */

View file

@ -25,6 +25,7 @@ export const SLIDE_OVER_EDIT_KEYS = [
"pm",
"serviceNotes",
"extraServices",
"avetaRequired",
"additionalContacts",
"vendorId",
// company/tech are display companions; board persists vendor via vendorId only

View file

@ -0,0 +1,49 @@
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
import { ServiceNotesDialog } from "@/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog";
import { renderWithProviders } from "@/test/test-utils";
describe("Aveta Service & Notes", () => {
it("shows the Aveta shield on the Service cell when required", () => {
renderWithProviders(
<PMTypeCell
value="HVAC"
pmTypes={["HVAC"]}
serviceNotes=""
extraServices={[]}
avetaRequired
onSave={vi.fn()}
onSaveDetails={vi.fn()}
/>,
);
expect(screen.getByLabelText("Aveta required")).toBeInTheDocument();
});
it("persists avetaRequired from Service & Notes", () => {
const onSave = vi.fn();
renderWithProviders(
<ServiceNotesDialog
open
onOpenChange={vi.fn()}
pm="HVAC"
extraServices={[]}
serviceNotes=""
avetaRequired={false}
pmTypes={["HVAC"]}
onSave={onSave}
/>,
);
fireEvent.click(screen.getByRole("checkbox", { name: /Requires Aveta documentation/i }));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
pm: "HVAC",
avetaRequired: true,
}),
);
});
});

View file

@ -37,7 +37,7 @@ describe("DueDateCell", () => {
<DueDateCell type="Emergency" value="2026-07-15" onSave={onSave} />,
);
fireEvent.click(screen.getByRole("button", { name: /07-15|7\/15|15/i }));
fireEvent.click(screen.getByRole("button", { name: "Due Date" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
rerender(

View file

@ -0,0 +1,18 @@
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { RescheduleCounterPill } from "@/app/(protected)/workorders/_components/list/table/cells/wo-counter-pills";
import { renderWithProviders } from "@/test/test-utils";
describe("RescheduleCounterPill", () => {
it("hides the pill when the count is below 2", () => {
const { container } = renderWithProviders(<RescheduleCounterPill count={1} />);
expect(container).toBeEmptyDOMElement();
});
it("renders a purple count pill with history tooltip from originalDate", () => {
renderWithProviders(<RescheduleCounterPill count={4} originalDate="2026-05-08" />);
const pill = screen.getByLabelText(/Rescheduled 4 times.*Original date: May 8, 2026/i);
expect(pill).toHaveStyle({ background: "#f5f3ff", color: "#7c3aed" });
expect(pill).toHaveTextContent("4");
});
});

View file

@ -0,0 +1,78 @@
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SlideOverInfoTabEditView } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view";
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { renderWithProviders } from "@/test/test-utils";
function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: 1,
woNumber: "24871",
site: "BK5",
type: "Emergency",
severity: 1,
dispatcherId: "",
dispatcherName: "",
scheduledOn: "2026-06-16",
completedDate: "",
targetWeek: "",
dayGroup: "",
dayLabel: "",
location: "Dallas",
locationId: "1",
pm: "Scrubber Pit",
serviceNotes: "",
extraServices: [],
vendorId: "",
company: "",
tech: "",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Scheduled",
docStatus: "No",
pocName: "",
pocPhone: "",
pocNotes: "",
additionalContacts: [],
dueDate: "2026-06-16",
rescheduleCount: 0,
carriedOver: 0,
isAddOn: false,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "",
priority: "",
primaryDispatchId: null,
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
upliftSummary: EMPTY_UPLIFT_SUMMARY,
...overrides,
};
}
describe("slide-over Emergency Due Date", () => {
it("opens the dedicated calendar dialog for Emergency", () => {
renderWithProviders(
<SlideOverInfoTabEditView
draft={row()}
users={[]}
sites={[]}
vendors={[]}
pmTypes={["Scrubber Pit"]}
allOrders={[]}
onDraftChange={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Due Date" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Due Date" })).toBeInTheDocument();
});
});

View file

@ -0,0 +1,30 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import { renderWithProviders } from "@/test/test-utils";
const photo: MediaFile = {
id: "m1",
name: "safety-form.pdf",
fileType: "pdf",
url: "blob:1",
category: "Report",
};
describe("SlideOverExtrasTab Aveta", () => {
it("labels the first extra document as Aveta when the WO is flagged", () => {
renderWithProviders(
<SlideOverExtrasTab
uploads={[photo]}
avetaRequired
isReadOnly
onUpload={vi.fn()}
onRemove={vi.fn()}
/>,
);
expect(screen.getAllByText("Aveta").length).toBeGreaterThan(0);
expect(screen.getByText("safety-form.pdf")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { resolveFirstCellShadow } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-style";
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: "1",
woNumber: "24877",
site: "BK5",
type: "PM",
severity: null,
dispatcherId: "",
dispatcherName: "",
scheduledOn: "",
completedDate: "",
targetWeek: "",
dayGroup: "",
dayLabel: "",
location: "",
locationId: "",
pm: "",
serviceNotes: "",
extraServices: [],
vendorId: "",
company: "",
tech: "",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Scheduled",
docStatus: "",
pocName: "",
pocPhone: "",
pocNotes: "",
additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
isAddOn: false,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "",
priority: "",
primaryDispatchId: null,
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
upliftSummary: EMPTY_UPLIFT_SUMMARY,
...overrides,
};
}
describe("resolveFirstCellShadow", () => {
it("uses a red inset for Emergency over Past Due", () => {
expect(resolveFirstCellShadow(row({ type: "Emergency", isPastDue: true }))).toBe(
"inset 3px 0 0 var(--destructive)",
);
});
it("uses a warning inset for Past Due when the type is not Emergency", () => {
expect(resolveFirstCellShadow(row({ isPastDue: true }))).toBe("inset 3px 0 0 var(--warning)");
});
it("has no inset for a regular row", () => {
expect(resolveFirstCellShadow(row())).toBe("none");
});
});

View file

@ -336,4 +336,25 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
expect(screen.getByRole("button", { name: "All dispatchers" })).toBeEnabled();
expect(screen.getByRole("button", { name: /work order types, all types/i })).toBeEnabled();
});
it("applies Aveta required only through Advanced Filters", () => {
const view = renderWithProviders(<WorkOrdersListPage />, {
route: "/workorders",
withAuth: false,
});
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
advOpen = true;
view.rerender(<WorkOrdersListPage />);
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
fireEvent.click(within(sheet).getByRole("button", { name: /Aveta Compliance/i }));
fireEvent.click(within(sheet).getByRole("checkbox", { name: /Aveta required only/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i }));
const appliedCalls = applyAdvancedFilters.mock.calls;
const applied = appliedCalls[appliedCalls.length - 1]?.[0] as typeof EMPTY_ADVANCED_FILTERS;
expect(applied.avetaOnly).toBe(true);
expect(toBoardSearchQueryParams({ filters: applied }).avetaOnly).toBe(true);
});
});

View file

@ -404,6 +404,16 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
expect(payload.trade).toBeUndefined();
});
it("sends avetaRequired on create when the Service & Notes flag is set", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
type: "PM",
siteCode: "BK5",
avetaRequired: true,
});
expect(payload.avetaRequired).toBe(true);
});
it("maps numeric WO numbers", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,

View file

@ -78,11 +78,13 @@ describe("expandTablePatchToBoardFieldOps", () => {
pm: "Backflow Preventers",
extraServices: ["Filter change", "Coil clean"],
serviceNotes: "service notes",
avetaRequired: true,
}),
).toEqual([
{ field: "pm", value: "Backflow Preventers" },
{ field: "extraServices", value: '["Filter change","Coil clean"]' },
{ field: "serviceNotes", value: "service notes" },
{ field: "avetaRequired", value: "true" },
]);
});

View file

@ -51,6 +51,27 @@ describe("mapWorkOrderTableRow isAddOn", () => {
});
});
describe("mapWorkOrderTableRow avetaRequired", () => {
it("maps avetaRequired and originalDate from the same board list row", () => {
const row = mapWorkOrderTableRow({
id: 8,
workOrderType: WORK_ORDER_TYPE_API.PM,
avetaRequired: true,
originalDate: "2026-05-08",
});
expect(row.avetaRequired).toBe(true);
expect(row.originalDate).toBe("2026-05-08");
});
it("defaults avetaRequired to false when omitted", () => {
const row = mapWorkOrderTableRow({
id: 8,
workOrderType: WORK_ORDER_TYPE_API.PM,
});
expect(row.avetaRequired).toBe(false);
});
});
describe("mapWorkOrderTableRow primaryDispatchStatus", () => {
it.each([
["primaryDispatchStatus", "Refused"],

View file

@ -138,6 +138,18 @@ describe("clearAdvancedFilterGroup", () => {
expect(chips.some((c) => c.key === "doc" && c.group === "Comp doc")).toBe(true);
});
it("builds an Aveta required chip", () => {
const filters = {
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
avetaOnly: true,
};
const chips = buildAdvancedFilterChips(filters);
expect(
chips.some((c) => c.key === "aveta" && c.group === "Aveta" && c.value === "Required"),
).toBe(true);
});
it("clears uplift chip and drops hasUplift with statuses", () => {
const filters = {
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),

View file

@ -218,6 +218,11 @@ describe("toBoardSearchQueryParams", () => {
expect(addOn.addOn).toBe(true);
expect(addOn.carriedOver).toBeUndefined();
const aveta = toBoardSearchQueryParams({
filters: { ...EMPTY_ADVANCED_FILTERS, avetaOnly: true },
});
expect(aveta.avetaOnly).toBe(true);
const service = toBoardSearchQueryParams({
filters: { ...EMPTY_ADVANCED_FILTERS, pmTypes: new Set(["HVAC"]) },
});

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import {
resolveExtraDocCategory,
shouldDefaultFirstExtraToAveta,
} from "@/domain/work-orders/utils/extra-docs-aveta";
function file(category: MediaFile["category"]): MediaFile {
return {
id: "1",
name: "doc.pdf",
fileType: "pdf",
url: "blob:1",
category,
};
}
describe("extra-docs-aveta", () => {
it("defaults the first extra file to Aveta when required and none is Aveta", () => {
const files = [file("Photo"), file("Report")];
expect(resolveExtraDocCategory(files[0], 0, true, files)).toBe("Aveta");
expect(resolveExtraDocCategory(files[1], 1, true, files)).toBe("Report");
expect(shouldDefaultFirstExtraToAveta(true, files)).toBe(true);
});
it("keeps an explicit Aveta file and does not relabel others", () => {
const files = [file("Photo"), file("Aveta")];
expect(resolveExtraDocCategory(files[0], 0, true, files)).toBe("Photo");
expect(resolveExtraDocCategory(files[1], 1, true, files)).toBe("Aveta");
expect(shouldDefaultFirstExtraToAveta(true, files)).toBe(false);
});
it("does not label extras as Aveta when the WO is not flagged", () => {
const files = [file("Photo")];
expect(resolveExtraDocCategory(files[0], 0, false, files)).toBe("Photo");
expect(shouldDefaultFirstExtraToAveta(false, files)).toBe(false);
});
});

View file

@ -65,7 +65,7 @@ describe("formatBoardAuditAction", () => {
oldValue: "false",
newValue: "true",
}),
).toBe("WO marked as Past Due. Due date has passed without completion.");
).toBe("WO marked as Past Due. Schedule On has passed without completion.");
});
it("falls back to from → to for unknown fields", () => {

View file

@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { formatRescheduleHover } from "@/domain/work-orders/utils/reschedule-indicator";
describe("formatRescheduleHover", () => {
it("includes the count without an original date when the API omits it", () => {
expect(formatRescheduleHover(4)).toBe(
"Rescheduled 4 times — this WO has had its date changed 4 times.",
);
});
it("appends the original date when the board DTO provides originalDate", () => {
expect(formatRescheduleHover(3, "2026-05-08")).toBe(
"Rescheduled 3 times — this WO has had its date changed 3 times. Original date: May 8, 2026.",
);
});
});

View file

@ -14,6 +14,10 @@ describe("hasServiceNotesDetails", () => {
expect(hasServiceNotesDetails("service notes", [])).toBe(true);
expect(hasServiceNotesDetails("", ["Filter change"])).toBe(true);
});
it("is true when Aveta is required even without notes", () => {
expect(hasServiceNotesDetails("", [], true)).toBe(true);
});
});
describe("truncateServiceNotesPreview", () => {

View file

@ -65,6 +65,7 @@ describe("slide-over-draft", () => {
expect(SLIDE_OVER_EDIT_KEYS).toContain("status");
expect(SLIDE_OVER_EDIT_KEYS).toContain("vendorId");
expect(SLIDE_OVER_EDIT_KEYS).toContain("additionalContacts");
expect(SLIDE_OVER_EDIT_KEYS).toContain("avetaRequired");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("pocName");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("techPhone");

View file

@ -376,6 +376,23 @@ describe("matchesAdvancedFilters", () => {
matchesAdvancedFilters(makeRow({ isPastDue: false, scheduledOn: weekMonday }), filters),
).toBe(false);
});
it("matches Aveta-only when avetaRequired is true", () => {
const weekMonday = mondayOf(todayIso());
const filters = {
...EMPTY_ADVANCED_FILTERS,
dateRange: "custom" as const,
customFrom: "0000-01-01",
customTo: "9999-12-31",
avetaOnly: true,
};
expect(
matchesAdvancedFilters(makeRow({ scheduledOn: weekMonday, avetaRequired: true }), filters),
).toBe(true);
expect(
matchesAdvancedFilters(makeRow({ scheduledOn: weekMonday, avetaRequired: false }), filters),
).toBe(false);
});
});
describe("computeIsPastDue", () => {