mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
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:
parent
cd092b3137
commit
7a30335ab8
59 changed files with 820 additions and 85 deletions
|
|
@ -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,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 }))}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -63,4 +63,8 @@ export function assignWizardServicePocFields(
|
|||
if (draft.severity != null) {
|
||||
payload.severity = draft.severity;
|
||||
}
|
||||
|
||||
if (draft.avetaRequired) {
|
||||
payload.avetaRequired = true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
26
src/domain/work-orders/utils/extra-docs-aveta.ts
Normal file
26
src/domain/work-orders/utils/extra-docs-aveta.ts
Normal 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");
|
||||
}
|
||||
16
src/domain/work-orders/utils/reschedule-indicator.ts
Normal file
16
src/domain/work-orders/utils/reschedule-indicator.ts
Normal 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}.`;
|
||||
}
|
||||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"]) },
|
||||
});
|
||||
|
|
|
|||
38
src/test/domain/work-orders/utils/extra-docs-aveta.test.ts
Normal file
38
src/test/domain/work-orders/utils/extra-docs-aveta.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue