fix(work-orders): persist Extra Docs Aveta category round-trip

Keep Extra uploads uncategorized until the dispatcher picks Aveta.
Drive the shield from hasAvetaDocument after reopen.
This commit is contained in:
Arthur Bassi 2026-08-25 15:11:25 -03:00
parent 838d79c2dd
commit 0a2544fae4
22 changed files with 234 additions and 23 deletions

View file

@ -37,7 +37,9 @@ export function SlideOverHeader({
#{row.woNumber || "—"}
</Text>
<RescheduleCounterPill count={row.rescheduleCount} originalDate={row.originalDate} />
{Boolean(row.avetaRequired) && <AvetaBadge size={13} />}
{Boolean(row.avetaRequired) && (
<AvetaBadge size={13} attached={Boolean(row.hasAvetaDocument)} />
)}
{isCancelled && (
<span

View file

@ -11,8 +11,12 @@ import {
} from "lucide-react";
import { toast } from "react-toastify";
import { Text } from "@/components/ui/text";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
import { resolveExtraDocCategory } from "@/domain/work-orders/utils/extra-docs-aveta";
import {
hasExplicitAvetaFile,
resolveExtraDocCategory,
} from "@/domain/work-orders/utils/extra-docs-aveta";
import {
canDeleteWorkOrderMedia,
formatMediaSizeLimit,
@ -29,6 +33,11 @@ const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
other: Paperclip,
};
const EXTRA_DOC_PURPOSES: Array<Extract<UploadableMediaCategory, "Extra" | "Aveta">> = [
"Extra",
"Aveta",
];
type SlideOverExtrasTabProps = {
uploads: MediaFile[];
isReadOnly: boolean;
@ -36,6 +45,7 @@ type SlideOverExtrasTabProps = {
avetaRequired?: boolean;
onUpload: (files: File[]) => void;
onRemove: (id: string) => void;
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
};
function ExtraDocRow({
@ -43,16 +53,20 @@ function ExtraDocRow({
isReadOnly,
pending,
onRemove,
onCategorize,
}: {
file: MediaFile;
isReadOnly: boolean;
pending: boolean;
onRemove: (id: string) => void;
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
}) {
const category = resolveExtraDocCategory(file);
const isAveta = category === "Aveta";
const ItemIcon = isAveta ? ShieldCheck : FILETYPE_ICON[file.fileType];
const showDelete = !isReadOnly && canDeleteWorkOrderMedia(file);
const showCategory = !isReadOnly && Boolean(onCategorize) && !file.isLegacy;
const purpose = file.purpose === "Aveta" || isAveta ? "Aveta" : "Extra";
return (
<div className="flex items-center gap-2 py-1">
@ -70,8 +84,34 @@ function ExtraDocRow({
>
{file.name}
</a>
{isAveta && (
<span style={{ fontSize: 10, fontWeight: 600, color: "var(--warning)" }}>Aveta</span>
{showCategory ? (
<select
aria-label={`Category for ${file.name}`}
value={purpose}
disabled={pending}
onChange={(event) => {
const value = event.target.value;
if (value === "Extra" || value === "Aveta") {
onCategorize?.(file.id, value);
}
}}
className="rounded border px-1 py-0.5 text-[10px]"
style={{
borderColor: "var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
}}
>
{EXTRA_DOC_PURPOSES.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
) : (
isAveta && (
<span style={{ fontSize: 10, fontWeight: 600, color: "var(--warning)" }}>Aveta</span>
)
)}
{showDelete && (
<button
@ -96,8 +136,10 @@ export function SlideOverExtrasTab({
avetaRequired = false,
onUpload,
onRemove,
onCategorize,
}: SlideOverExtrasTabProps) {
const inputRef = useRef<HTMLInputElement>(null);
const avetaAttached = hasExplicitAvetaFile(uploads);
const addFiles = (files: FileList | null) => {
if (isReadOnly || pending || !files || files.length === 0) return;
@ -128,7 +170,11 @@ export function SlideOverExtrasTab({
{Boolean(avetaRequired) && (
<span
className="inline-flex items-center gap-1"
style={{ fontSize: 11, fontWeight: 500, color: "var(--warning)" }}
style={{
fontSize: 11,
fontWeight: 500,
color: avetaAttached ? "var(--success)" : "var(--warning)",
}}
>
<ShieldCheck size={12} />
Aveta
@ -193,6 +239,7 @@ export function SlideOverExtrasTab({
isReadOnly={isReadOnly}
pending={pending}
onRemove={onRemove}
onCategorize={onCategorize}
/>
))}
</div>

View file

@ -102,6 +102,7 @@ export function SlideOverInfoTabDisplayView({
serviceNotes={source.serviceNotes}
extraServices={source.extraServices}
avetaRequired={source.avetaRequired}
avetaAttached={source.hasAvetaDocument}
woNumber={source.woNumber}
viewOnly
onSave={() => undefined}
@ -110,7 +111,9 @@ 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} />}
{Boolean(source.avetaRequired) && (
<AvetaBadge size={11} attached={Boolean(source.hasAvetaDocument)} />
)}
</span>
) : (
<span style={{ color: "var(--color-text-muted)" }}>—</span>

View file

@ -44,7 +44,9 @@ export function useSlideOverMediaState({
const extrasUploads = useMemo(
() =>
boardMediaItemsToMediaFiles(
(detailForRow?.media ?? []).filter((item) => item.category === "Extra"),
(detailForRow?.media ?? []).filter(
(item) => item.category === "Extra" || item.category === "Aveta",
),
),
[detailForRow?.media],
);

View file

@ -158,6 +158,7 @@ export function WorkOrderSlideOverTabs({
void state.uploadExtraFiles(files);
}}
onRemove={state.removeMedia}
onCategorize={state.categorizeMedia}
/>
</WorkOrderSlideOverTabPanel>
)}

View file

@ -3,14 +3,21 @@ import { Tooltip } from "@mui/material";
type AvetaBadgeProps = {
size?: number;
attached?: boolean;
};
/** Per-WO Aveta shield — never a site-level flag. Color is paired with the icon. */
export function AvetaBadge({ size = 11 }: AvetaBadgeProps) {
export function AvetaBadge({ size = 11, attached = false }: AvetaBadgeProps) {
const color = attached ? "var(--success)" : "var(--warning)";
const title = attached
? "Aveta documentation is attached"
: "This work order requires Aveta compliance documentation";
const label = attached ? "Aveta attached" : "Aveta required";
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)" }} />
<Tooltip title={title} arrow placement="top">
<span className="inline-flex shrink-0" aria-label={label}>
<ShieldCheck size={size} style={{ color }} />
</span>
</Tooltip>
);

View file

@ -22,6 +22,7 @@ type PMTypeCellProps = {
serviceNotes?: string;
extraServices?: string[];
avetaRequired?: boolean;
avetaAttached?: boolean;
woNumber?: string;
onSaveDetails?: (details: ServiceNotesDetails) => void;
viewOnly?: boolean;
@ -35,6 +36,7 @@ export function PMTypeCell({
serviceNotes = "",
extraServices = [],
avetaRequired = false,
avetaAttached = false,
woNumber,
onSaveDetails,
viewOnly = false,
@ -99,7 +101,7 @@ export function PMTypeCell({
) : (
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>—</span>
)}
{Boolean(avetaRequired) && <AvetaBadge size={11} />}
{Boolean(avetaRequired) && <AvetaBadge size={11} attached={avetaAttached} />}
</button>
{modal}
</>
@ -115,6 +117,7 @@ export function PMTypeCell({
notePreview={notePreview}
extrasCount={extras.length}
avetaRequired={avetaRequired}
avetaAttached={avetaAttached}
onOpenDetails={() => setModalOpen(true)}
/>
{modal}

View file

@ -8,6 +8,7 @@ type PmTypeDetailsTriggerProps = {
notePreview: string;
extrasCount: number;
avetaRequired?: boolean;
avetaAttached?: boolean;
onOpenDetails: () => void;
};
@ -17,6 +18,7 @@ export function PmTypeDetailsTrigger({
notePreview,
extrasCount,
avetaRequired = false,
avetaAttached = false,
onOpenDetails,
}: PmTypeDetailsTriggerProps) {
return (
@ -44,7 +46,7 @@ export function PmTypeDetailsTrigger({
>
{value ? <HighlightText text={value} q={q} /> : "Select …"}
</span>
{Boolean(avetaRequired) && <AvetaBadge size={11} />}
{Boolean(avetaRequired) && <AvetaBadge size={11} attached={avetaAttached} />}
</span>
{notePreview ? (
<span

View file

@ -50,6 +50,7 @@ export function WoTableRowServiceCells({
serviceNotes={row.serviceNotes}
extraServices={row.extraServices}
avetaRequired={row.avetaRequired}
avetaAttached={row.hasAvetaDocument}
woNumber={row.woNumber}
viewOnly={viewOnly}
onSave={(pm) => onPatchRow({ pm })}

View file

@ -99,11 +99,13 @@ function mapDocStatus(raw: unknown): CompletionDocStatus {
}
function mapMediaCategory(raw: unknown): BoardMediaCategory {
if (raw === "Aveta" || raw === "aveta") return "Aveta";
const numeric = typeof raw === "number" ? raw : Number(raw);
if (numeric === BOARD_MEDIA_CATEGORY_API.Before) return "Before";
if (numeric === BOARD_MEDIA_CATEGORY_API.After) return "After";
if (numeric === BOARD_MEDIA_CATEGORY_API.Extra) return "Extra";
if (numeric === BOARD_MEDIA_CATEGORY_API.Completion) return "Completion";
if (numeric === BOARD_MEDIA_CATEGORY_API.Aveta) return "Aveta";
return "Extra";
}
@ -145,7 +147,7 @@ export function mapBoardDetailAuditEntry(raw: unknown, index: number): WorkOrder
};
}
export type UploadableMediaCategory = "Before" | "After" | "Extra";
export type UploadableMediaCategory = "Before" | "After" | "Extra" | "Aveta";
export function buildMediaFormData(file: File, category?: UploadableMediaCategory): FormData {
const form = new FormData();
@ -246,6 +248,7 @@ function classifyFileName(fileName: string, url: string): FileCategory {
}
function boardCategoryToMediaCategory(category: BoardMediaCategory): MediaCategory {
if (category === "Aveta") return "Aveta";
if (category === "Extra") return "Other";
if (category === "Completion") return "Report";
return "Photo";

View file

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

View file

@ -2,18 +2,19 @@ import type { WorkOrderAuditEntry, WorkOrderComment } from "./work-order-detail"
import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
import type { CompletionDocStatus, WorkOrderTableRow } from "./work-order-table-row";
/** API media.category: 1=Before, 2=After, 3=Extra, 4=Completion */
/** API media.category: 1=Before, 2=After, 3=Extra, 4=Completion, 5=Aveta */
export const BOARD_MEDIA_CATEGORY_API = {
Before: 1,
After: 2,
Extra: 3,
Completion: 4,
Aveta: 5,
} as const;
export type BoardMediaCategoryApi =
(typeof BOARD_MEDIA_CATEGORY_API)[keyof typeof BOARD_MEDIA_CATEGORY_API];
export type BoardMediaCategory = "Before" | "After" | "Extra" | "Completion";
export type BoardMediaCategory = "Before" | "After" | "Extra" | "Completion" | "Aveta";
export interface WorkOrderBoardDetailInfoApi {
id: number;
@ -52,6 +53,8 @@ export interface WorkOrderBoardDetailInfoApi {
extraServices?: string[] | null;
/** Per-WO Aveta compliance flag (Service & Notes). */
avetaRequired?: boolean | null;
/** True when an Extra Docs file is categorized Aveta. */
hasAvetaDocument?: boolean | null;
docStatus?: number | null;
flagColor?: string | null;
primaryDispatchId?: number | null;

View file

@ -160,6 +160,8 @@ export interface WorkOrderBoardRowApi {
isAddOn?: boolean | null;
/** Per-WO Aveta compliance flag (Service & Notes). */
avetaRequired?: boolean | null;
/** True when an Extra Docs file is categorized Aveta. */
hasAvetaDocument?: boolean | null;
/** Original schedule snapshot for Reschedule hover history. */
originalDate?: string | null;
originalWeek?: string | null;

View file

@ -9,8 +9,8 @@ export interface MediaFile {
fileType: FileCategory;
url: string;
category: MediaCategory;
/** Board purpose label (Before/After/Extra/Completion). Null/undefined = needs categorization. */
purpose?: "Before" | "After" | "Extra" | "Completion" | null;
/** Board purpose label (Before/After/Extra/Completion/Aveta). Null/undefined = needs categorization. */
purpose?: "Before" | "After" | "Extra" | "Completion" | "Aveta" | null;
/** True when media is a legacy Before/After/Completion column (not deletable). */
isLegacy?: boolean;
/** 0–100 while uploading; undefined when idle/complete. */

View file

@ -59,6 +59,8 @@ export interface WorkOrderTableRow {
isAddOn: boolean;
/** Per-WO Aveta compliance flag from Service & Notes (not a site attribute). */
avetaRequired?: boolean;
/** True when an Extra Docs file is categorized Aveta. */
hasAvetaDocument?: boolean;
originalWeek: string;
originalDate: string;
isPastDue: boolean;

View file

@ -1,14 +1,23 @@
import type { MediaCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
/**
* Extra Docs overlay category. Board purpose stays Extra
* (API enum Before/After/Extra/Completion). Aveta is never inferred from
* upload order — only an explicit Aveta category counts.
* Extra Docs Aveta is an explicit API media category (value 5).
* Upload stays Extra; only an explicit Aveta category counts as attached.
*/
export function resolveExtraDocCategory(file: MediaFile): MediaCategory {
if (file.purpose === "Aveta" || file.category === "Aveta") {
return "Aveta";
}
return file.category;
}
export function hasExplicitAvetaFile(files: MediaFile[]): boolean {
return files.some((item) => item.category === "Aveta");
return files.some((item) => resolveExtraDocCategory(item) === "Aveta");
}
export function isAvetaDocumentAttached(
hasAvetaDocument: boolean | undefined,
files: MediaFile[] = [],
): boolean {
return Boolean(hasAvetaDocument) || hasExplicitAvetaFile(files);
}

View file

@ -21,6 +21,23 @@ describe("Aveta Service & Notes", () => {
expect(screen.getByLabelText("Aveta required")).toBeInTheDocument();
});
it("shows the green Aveta shield when the document is attached", () => {
renderWithProviders(
<PMTypeCell
value="HVAC"
pmTypes={["HVAC"]}
serviceNotes=""
extraServices={[]}
avetaRequired
avetaAttached
onSave={vi.fn()}
onSaveDetails={vi.fn()}
/>,
);
expect(screen.getByLabelText("Aveta attached")).toBeInTheDocument();
});
it("persists avetaRequired from Service & Notes", () => {
const onSave = vi.fn();
renderWithProviders(

View file

@ -1,4 +1,4 @@
import { screen } from "@testing-library/react";
import { fireEvent, 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";
@ -51,4 +51,24 @@ describe("SlideOverExtrasTab Aveta", () => {
expect(screen.getByText("aveta-clearance.pdf")).toBeInTheDocument();
expect(screen.getAllByText("Aveta").length).toBeGreaterThanOrEqual(2);
});
it("categorizes an Extra file as Aveta without auto-labeling the first upload", () => {
const onCategorize = vi.fn();
renderWithProviders(
<SlideOverExtrasTab
uploads={[photo]}
avetaRequired
isReadOnly={false}
onUpload={vi.fn()}
onRemove={vi.fn()}
onCategorize={onCategorize}
/>,
);
fireEvent.change(screen.getByLabelText("Category for safety-form.pdf"), {
target: { value: "Aveta" },
});
expect(onCategorize).toHaveBeenCalledWith("m1", "Aveta");
expect(screen.queryByText("safety-form.pdf")).toBeInTheDocument();
});
});

View file

@ -143,4 +143,38 @@ describe("useSlideOverMediaState completion filter", () => {
expect(result.current.extrasUploads).toHaveLength(1);
expect(result.current.extrasUploads[0]?.purpose).toBe("Extra");
});
it("includes Aveta-categorized extra documents in extrasUploads", () => {
const detail = detailWithMedia([
{
id: "4",
category: "Extra",
url: "https://cdn.example.com/extra.pdf",
fileName: "extra.pdf",
uploadedAt: "2026-07-15T18:00:00.0000000Z",
isLegacy: false,
},
{
id: "5",
category: "Aveta",
url: "https://cdn.example.com/aveta.pdf",
fileName: "aveta.pdf",
uploadedAt: "2026-07-15T18:00:00.0000000Z",
isLegacy: false,
},
]);
const { result } = renderHook(() =>
useSlideOverMediaState({
detailForRow: detail,
uploadMediaMutation: idleMutation(),
deleteMediaMutation: idleMutation(),
categorizeMediaMutation: idleMutation(),
}),
);
expect(result.current.extrasUploads).toHaveLength(2);
expect(result.current.extrasUploads.map((item) => item.purpose)).toEqual(["Extra", "Aveta"]);
expect(result.current.extrasUploads[1]?.category).toBe("Aveta");
});
});

View file

@ -189,6 +189,28 @@ describe("mapWorkOrderBoardDetail", () => {
expect(files[1]?.fileType).toBe("pdf");
});
it("maps Aveta board category to Extra Docs Aveta", () => {
const aveta = mapBoardDetailMediaItem({
id: 22,
category: 5,
url: "https://cdn.example.com/aveta.pdf",
fileName: "aveta.pdf",
});
const fromName = mapBoardDetailMediaItem({
id: 23,
category: "Aveta",
url: "https://cdn.example.com/aveta.pdf",
fileName: "aveta.pdf",
});
const files = boardMediaItemsToMediaFiles([aveta, fromName]);
expect(files[0]).toMatchObject({
category: "Aveta",
purpose: "Aveta",
fileType: "pdf",
});
expect(files[1]?.purpose).toBe("Aveta");
});
it("maps After and Completion board categories to purpose for completion UI", () => {
const after = mapBoardDetailMediaItem({
id: 20,

View file

@ -70,6 +70,23 @@ describe("mapWorkOrderTableRow avetaRequired", () => {
});
expect(row.avetaRequired).toBe(false);
});
it("maps hasAvetaDocument from the board list row", () => {
const attached = mapWorkOrderTableRow({
id: 9,
workOrderType: WORK_ORDER_TYPE_API.PM,
avetaRequired: true,
hasAvetaDocument: true,
});
expect(attached.hasAvetaDocument).toBe(true);
const pending = mapWorkOrderTableRow({
id: 10,
workOrderType: WORK_ORDER_TYPE_API.PM,
avetaRequired: true,
});
expect(pending.hasAvetaDocument).toBe(false);
});
});
describe("mapWorkOrderTableRow primaryDispatchStatus", () => {

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import {
hasExplicitAvetaFile,
isAvetaDocumentAttached,
resolveExtraDocCategory,
} from "@/domain/work-orders/utils/extra-docs-aveta";
@ -35,4 +36,16 @@ describe("extra-docs-aveta", () => {
expect(resolveExtraDocCategory(files[0])).toBe("Photo");
expect(hasExplicitAvetaFile(files)).toBe(false);
});
it("treats purpose Aveta as an explicit Aveta file", () => {
const aveta: MediaFile = { ...file("Other"), purpose: "Aveta" };
expect(resolveExtraDocCategory(aveta)).toBe("Aveta");
expect(hasExplicitAvetaFile([aveta])).toBe(true);
expect(isAvetaDocumentAttached(false, [aveta])).toBe(true);
});
it("uses hasAvetaDocument from the board row after reopen", () => {
expect(isAvetaDocumentAttached(true, [])).toBe(true);
expect(isAvetaDocumentAttached(false, [file("Other")])).toBe(false);
});
});