([]);
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}
/>
@@ -116,6 +123,7 @@ export function ServiceNotesDialog({
pm: sel[0] ?? "",
extraServices: sel.slice(1),
serviceNotes: note,
+ avetaRequired: aveta,
});
onOpenChange(false);
}}
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/wo-counter-pills.tsx b/src/app/(protected)/workorders/_components/list/table/cells/wo-counter-pills.tsx
new file mode 100644
index 00000000..3dad9c2d
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/table/cells/wo-counter-pills.tsx
@@ -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 (
+
+
+
+ {count}
+
+
+ );
+}
+
+export function CarriedOverCounterPill({ count }: { count: number }) {
+ if (count < 2) {
+ return null;
+ }
+
+ return (
+
+ {count}
+
+ );
+}
+
+export function AddOnPill({ when }: { when: boolean }) {
+ if (!when) {
+ return null;
+ }
+
+ return (
+
+ Add-On
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
index 11ecf728..8f6482ea 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
@@ -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,
})
}
/>
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx
index 3e64f0cc..bb904aaa 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx
@@ -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}
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx
index 3d3e3fc6..1c6ac5ae 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx
@@ -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,
})
}
/>
diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields.tsx
index 505a2f7c..1f0dafc2 100644
--- a/src/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields.tsx
+++ b/src/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields.tsx
@@ -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({
)}
+ {onAvetaRequiredChange != null && (
+
+ )}
Notes / Issue Detail
)}
diff --git a/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts b/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts
index e6041f83..32e87840 100644
--- a/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts
+++ b/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts
@@ -63,4 +63,8 @@ export function assignWizardServicePocFields(
if (draft.severity != null) {
payload.severity = draft.severity;
}
+
+ if (draft.avetaRequired) {
+ payload.avetaRequired = true;
+ }
}
diff --git a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts
index 49d3e157..7266fbec 100644
--- a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts
+++ b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts
@@ -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",
diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
index cea22e59..dc4c878f 100644
--- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
+++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
@@ -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"),
diff --git a/src/domain/work-orders/types/work-order-advanced-filters.ts b/src/domain/work-orders/types/work-order-advanced-filters.ts
index ac8dc872..3157bca1 100644
--- a/src/domain/work-orders/types/work-order-advanced-filters.ts
+++ b/src/domain/work-orders/types/work-order-advanced-filters.ts
@@ -34,6 +34,7 @@ export interface WorkOrderAdvancedFilters {
rescheduled: boolean;
carriedOver: boolean;
addOn: boolean;
+ avetaOnly: boolean;
pmTypes: Set;
vendorTechs: Set;
docs: Set;
@@ -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(),
diff --git a/src/domain/work-orders/types/work-order-board-detail.ts b/src/domain/work-orders/types/work-order-board-detail.ts
index 8e691382..1b4bd2b5 100644
--- a/src/domain/work-orders/types/work-order-board-detail.ts
+++ b/src/domain/work-orders/types/work-order-board-detail.ts
@@ -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;
diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts
index dfad8c12..d2f5e13d 100644
--- a/src/domain/work-orders/types/work-order-board.ts
+++ b/src/domain/work-orders/types/work-order-board.ts
@@ -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;
diff --git a/src/domain/work-orders/types/work-order-media.ts b/src/domain/work-orders/types/work-order-media.ts
index a5909850..ba4d018a 100644
--- a/src/domain/work-orders/types/work-order-media.ts
+++ b/src/domain/work-orders/types/work-order-media.ts
@@ -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",
];
diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts
index d07549d7..deb2fb7f 100644
--- a/src/domain/work-orders/types/work-order-table-row.ts
+++ b/src/domain/work-orders/types/work-order-table-row.ts
@@ -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: "",
diff --git a/src/domain/work-orders/types/work-order-wizard.ts b/src/domain/work-orders/types/work-order-wizard.ts
index cc4c1ad7..de751c79 100644
--- a/src/domain/work-orders/types/work-order-wizard.ts
+++ b/src/domain/work-orders/types/work-order-wizard.ts
@@ -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: "",
diff --git a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts
index c43d5d55..4960e6dd 100644
--- a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts
+++ b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts
@@ -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(
diff --git a/src/domain/work-orders/utils/advanced-filter-predicates.ts b/src/domain/work-orders/utils/advanced-filter-predicates.ts
index 112204c8..c4f165f8 100644
--- a/src/domain/work-orders/utils/advanced-filter-predicates.ts
+++ b/src/domain/work-orders/utils/advanced-filter-predicates.ts
@@ -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,
];
diff --git a/src/domain/work-orders/utils/advanced-filters-utils.ts b/src/domain/work-orders/utils/advanced-filters-utils.ts
index 8266a7b0..b98d2ef5 100644
--- a/src/domain/work-orders/utils/advanced-filters-utils.ts
+++ b/src/domain/work-orders/utils/advanced-filters-utils.ts
@@ -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),
diff --git a/src/domain/work-orders/utils/board-audit-action-rules.ts b/src/domain/work-orders/utils/board-audit-action-rules.ts
index 505275dd..d286485d 100644
--- a/src/domain/work-orders/utils/board-audit-action-rules.ts
+++ b/src/domain/work-orders/utils/board-audit-action-rules.ts
@@ -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 {
diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts
index 4f8b771c..a930d874 100644
--- a/src/domain/work-orders/utils/board-query-params.ts
+++ b/src/domain/work-orders/utils/board-query-params.ts
@@ -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,
diff --git a/src/domain/work-orders/utils/extra-docs-aveta.ts b/src/domain/work-orders/utils/extra-docs-aveta.ts
new file mode 100644
index 00000000..23064d33
--- /dev/null
+++ b/src/domain/work-orders/utils/extra-docs-aveta.ts
@@ -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");
+}
diff --git a/src/domain/work-orders/utils/reschedule-indicator.ts b/src/domain/work-orders/utils/reschedule-indicator.ts
new file mode 100644
index 00000000..26bee35e
--- /dev/null
+++ b/src/domain/work-orders/utils/reschedule-indicator.ts
@@ -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}.`;
+}
diff --git a/src/domain/work-orders/utils/service-notes-ui.ts b/src/domain/work-orders/utils/service-notes-ui.ts
index 9d806960..4128ff46 100644
--- a/src/domain/work-orders/utils/service-notes-ui.ts
+++ b/src/domain/work-orders/utils/service-notes-ui.ts
@@ -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). */
diff --git a/src/domain/work-orders/utils/slide-over-draft.ts b/src/domain/work-orders/utils/slide-over-draft.ts
index 0223f99d..615d9f53 100644
--- a/src/domain/work-orders/utils/slide-over-draft.ts
+++ b/src/domain/work-orders/utils/slide-over-draft.ts
@@ -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
diff --git a/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx b/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx
new file mode 100644
index 00000000..ca4ffead
--- /dev/null
+++ b/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx
@@ -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(
+ ,
+ );
+
+ expect(screen.getByLabelText("Aveta required")).toBeInTheDocument();
+ });
+
+ it("persists avetaRequired from Service & Notes", () => {
+ const onSave = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ 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,
+ }),
+ );
+ });
+});
diff --git a/src/test/app/(protected)/workorders/due-date-calendar-dialog.test.tsx b/src/test/app/(protected)/workorders/due-date-calendar-dialog.test.tsx
index abe7c429..66326982 100644
--- a/src/test/app/(protected)/workorders/due-date-calendar-dialog.test.tsx
+++ b/src/test/app/(protected)/workorders/due-date-calendar-dialog.test.tsx
@@ -37,7 +37,7 @@ describe("DueDateCell", () => {
,
);
- 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(
diff --git a/src/test/app/(protected)/workorders/reschedule-counter-pill.test.tsx b/src/test/app/(protected)/workorders/reschedule-counter-pill.test.tsx
new file mode 100644
index 00000000..8f81de8d
--- /dev/null
+++ b/src/test/app/(protected)/workorders/reschedule-counter-pill.test.tsx
@@ -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();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders a purple count pill with history tooltip from originalDate", () => {
+ renderWithProviders();
+ const pill = screen.getByLabelText(/Rescheduled 4 times.*Original date: May 8, 2026/i);
+ expect(pill).toHaveStyle({ background: "#f5f3ff", color: "#7c3aed" });
+ expect(pill).toHaveTextContent("4");
+ });
+});
diff --git a/src/test/app/(protected)/workorders/slide-over-emergency-due-date.test.tsx b/src/test/app/(protected)/workorders/slide-over-emergency-due-date.test.tsx
new file mode 100644
index 00000000..62cb3d90
--- /dev/null
+++ b/src/test/app/(protected)/workorders/slide-over-emergency-due-date.test.tsx
@@ -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 {
+ 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(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Due Date" }));
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "Due Date" })).toBeInTheDocument();
+ });
+});
diff --git a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx
new file mode 100644
index 00000000..25724db6
--- /dev/null
+++ b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx
@@ -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(
+ ,
+ );
+
+ expect(screen.getAllByText("Aveta").length).toBeGreaterThan(0);
+ expect(screen.getByText("safety-form.pdf")).toBeInTheDocument();
+ });
+});
diff --git a/src/test/app/(protected)/workorders/wo-table-row-style.test.ts b/src/test/app/(protected)/workorders/wo-table-row-style.test.ts
new file mode 100644
index 00000000..0539e408
--- /dev/null
+++ b/src/test/app/(protected)/workorders/wo-table-row-style.test.ts
@@ -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 {
+ 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");
+ });
+});
diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx
index 80ffa03a..d62baec0 100644
--- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx
+++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx
@@ -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(, {
+ route: "/workorders",
+ withAuth: false,
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
+ advOpen = true;
+ view.rerender();
+
+ 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);
+ });
});
diff --git a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts
index eff3b3fc..b9b1511d 100644
--- a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts
@@ -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,
diff --git a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts
index 8e728756..3aae3dc6 100644
--- a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts
@@ -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" },
]);
});
diff --git a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts
index 74bdc2bb..09607004 100644
--- a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts
@@ -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"],
diff --git a/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts b/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts
index 0e5126fd..ad69e440 100644
--- a/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts
+++ b/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts
@@ -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),
diff --git a/src/test/domain/work-orders/utils/board-query-params.test.ts b/src/test/domain/work-orders/utils/board-query-params.test.ts
index 45d28cab..4c81c36b 100644
--- a/src/test/domain/work-orders/utils/board-query-params.test.ts
+++ b/src/test/domain/work-orders/utils/board-query-params.test.ts
@@ -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"]) },
});
diff --git a/src/test/domain/work-orders/utils/extra-docs-aveta.test.ts b/src/test/domain/work-orders/utils/extra-docs-aveta.test.ts
new file mode 100644
index 00000000..45b05469
--- /dev/null
+++ b/src/test/domain/work-orders/utils/extra-docs-aveta.test.ts
@@ -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);
+ });
+});
diff --git a/src/test/domain/work-orders/utils/format-board-audit-action.test.ts b/src/test/domain/work-orders/utils/format-board-audit-action.test.ts
index 9cfdef81..b2a9e67d 100644
--- a/src/test/domain/work-orders/utils/format-board-audit-action.test.ts
+++ b/src/test/domain/work-orders/utils/format-board-audit-action.test.ts
@@ -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", () => {
diff --git a/src/test/domain/work-orders/utils/reschedule-indicator.test.ts b/src/test/domain/work-orders/utils/reschedule-indicator.test.ts
new file mode 100644
index 00000000..72f48f86
--- /dev/null
+++ b/src/test/domain/work-orders/utils/reschedule-indicator.test.ts
@@ -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.",
+ );
+ });
+});
diff --git a/src/test/domain/work-orders/utils/service-notes-ui.test.ts b/src/test/domain/work-orders/utils/service-notes-ui.test.ts
index 8ded7e9d..a2ff4416 100644
--- a/src/test/domain/work-orders/utils/service-notes-ui.test.ts
+++ b/src/test/domain/work-orders/utils/service-notes-ui.test.ts
@@ -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", () => {
diff --git a/src/test/domain/work-orders/utils/slide-over-draft.test.ts b/src/test/domain/work-orders/utils/slide-over-draft.test.ts
index 885b7780..ab31f5a5 100644
--- a/src/test/domain/work-orders/utils/slide-over-draft.test.ts
+++ b/src/test/domain/work-orders/utils/slide-over-draft.test.ts
@@ -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");
diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts
index 35071265..fb68c6f4 100644
--- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts
+++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts
@@ -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", () => {