diff --git a/src/app/(protected)/workorders/_components/detail/slide-over-header.tsx b/src/app/(protected)/workorders/_components/detail/slide-over-header.tsx index 6407e109..32facebb 100644 --- a/src/app/(protected)/workorders/_components/detail/slide-over-header.tsx +++ b/src/app/(protected)/workorders/_components/detail/slide-over-header.tsx @@ -37,7 +37,9 @@ export function SlideOverHeader({ #{row.woNumber || "—"} - {Boolean(row.avetaRequired) && } + {Boolean(row.avetaRequired) && ( + + )} {isCancelled && ( = { other: Paperclip, }; +const EXTRA_DOC_PURPOSES: Array> = [ + "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 (
@@ -70,8 +84,34 @@ function ExtraDocRow({ > {file.name} - {isAveta && ( - Aveta + {showCategory ? ( + + ) : ( + isAveta && ( + Aveta + ) )} {showDelete && (
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx index ea2aafc1..2f497d10 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx @@ -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({ {source.pm} - {Boolean(source.avetaRequired) && } + {Boolean(source.avetaRequired) && ( + + )} ) : ( — diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts index 15e6765b..4940b81d 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts @@ -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], ); diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx index 3a653c78..ebd41392 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx @@ -158,6 +158,7 @@ export function WorkOrderSlideOverTabs({ void state.uploadExtraFiles(files); }} onRemove={state.removeMedia} + onCategorize={state.categorizeMedia} /> )} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/aveta-badge.tsx b/src/app/(protected)/workorders/_components/list/table/cells/aveta-badge.tsx index 0f67b21c..d5993fc5 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/aveta-badge.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/aveta-badge.tsx @@ -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 ( - - - + + + ); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx index 5da468d2..be03e7fe 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx @@ -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({ ) : ( — )} - {Boolean(avetaRequired) && } + {Boolean(avetaRequired) && } {modal} @@ -115,6 +117,7 @@ export function PMTypeCell({ notePreview={notePreview} extrasCount={extras.length} avetaRequired={avetaRequired} + avetaAttached={avetaAttached} onOpenDetails={() => setModalOpen(true)} /> {modal} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger.tsx index 20b7708d..3bb7d5aa 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger.tsx @@ -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 ? : "Select …"} - {Boolean(avetaRequired) && } + {Boolean(avetaRequired) && }
{notePreview ? ( onPatchRow({ pm })} diff --git a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts index 4612756d..950f8fd2 100644 --- a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts @@ -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"; 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 dc4c878f..da4cff15 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 @@ -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"), 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 1b4bd2b5..06cf70e3 100644 --- a/src/domain/work-orders/types/work-order-board-detail.ts +++ b/src/domain/work-orders/types/work-order-board-detail.ts @@ -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; diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index d2f5e13d..cbd48acd 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -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; diff --git a/src/domain/work-orders/types/work-order-media.ts b/src/domain/work-orders/types/work-order-media.ts index ba4d018a..58c1f1f5 100644 --- a/src/domain/work-orders/types/work-order-media.ts +++ b/src/domain/work-orders/types/work-order-media.ts @@ -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. */ 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 deb2fb7f..fa7b7a63 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -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; diff --git a/src/domain/work-orders/utils/extra-docs-aveta.ts b/src/domain/work-orders/utils/extra-docs-aveta.ts index ce74f02d..d8fff131 100644 --- a/src/domain/work-orders/utils/extra-docs-aveta.ts +++ b/src/domain/work-orders/utils/extra-docs-aveta.ts @@ -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); } diff --git a/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx b/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx index ca4ffead..3df1516b 100644 --- a/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx +++ b/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx @@ -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( + , + ); + + expect(screen.getByLabelText("Aveta attached")).toBeInTheDocument(); + }); + it("persists avetaRequired from Service & Notes", () => { const onSave = vi.fn(); renderWithProviders( 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 index de6af331..75c36f00 100644 --- a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx @@ -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( + , + ); + + fireEvent.change(screen.getByLabelText("Category for safety-form.pdf"), { + target: { value: "Aveta" }, + }); + expect(onCategorize).toHaveBeenCalledWith("m1", "Aveta"); + expect(screen.queryByText("safety-form.pdf")).toBeInTheDocument(); + }); }); diff --git a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx index e4d7d670..d3504fbc 100644 --- a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx +++ b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx @@ -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"); + }); }); diff --git a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts index edfd4067..21867fa4 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts @@ -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, 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 09607004..b0208e84 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 @@ -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", () => { 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 index ddc4b562..88aed90a 100644 --- a/src/test/domain/work-orders/utils/extra-docs-aveta.test.ts +++ b/src/test/domain/work-orders/utils/extra-docs-aveta.test.ts @@ -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); + }); });