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 e2ae7411..54b333ed 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 @@ -269,6 +269,20 @@ export function boardMediaItemsToMediaFiles(items: WorkOrderBoardMediaItem[]): M })); } +/** + * `GET workorders/{id}/detail` info serializes the board row's `MediaCount` as 0 — the detail + * DTO inherits the field but the service never sets it — so trusting it verbatim would leave the + * slide-over closability gate reporting "At least 1 photo" even after an upload. Take the larger + * of the reported count and the stored (non-legacy) attachments: a positive API count still wins, + * but a spurious (or omitted) 0 can no longer mask media that are actually present. + */ +function resolveDetailMediaCount(infoRaw: unknown, media: WorkOrderBoardMediaItem[]): number { + const record = asRecord(infoRaw); + const explicit = record.mediaCount ?? record.MediaCount; + const nonLegacyCount = media.filter((item) => !item.isLegacy).length; + return Math.max(typeof explicit === "number" ? explicit : 0, nonLegacyCount); +} + export function mapWorkOrderBoardDetail(raw: unknown): WorkOrderBoardDetail { const root = asRecord(raw); const infoRaw = root.info ?? root.Info ?? root; @@ -283,8 +297,10 @@ export function mapWorkOrderBoardDetail(raw: unknown): WorkOrderBoardDetail { : []; const media = Array.isArray(mediaRaw) ? mediaRaw.map(mapBoardDetailMediaItem) : []; + const info = mapWorkOrderTableRow(infoRaw); + return { - info: mapWorkOrderTableRow(infoRaw), + info: { ...info, mediaCount: resolveDetailMediaCount(infoRaw, media) }, completion: mapCompletion(completionRaw ?? {}), comments, audit, 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 7f34bcc8..fb24f6b5 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 @@ -6,6 +6,10 @@ import { mapBoardDetailMediaItem, mapWorkOrderBoardDetail, } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; +import { + getClosabilityGaps, + tableRowToClosabilityInput, +} from "@/domain/work-orders/utils/wo-closability"; const SAMPLE_DETAIL = { info: { @@ -279,3 +283,56 @@ describe("mapWorkOrderBoardDetail", () => { expect(completionForm.get("file")).toBeInstanceOf(File); }); }); + +describe("mapWorkOrderBoardDetail media count (SH-339)", () => { + const reactiveInfo = { ...SAMPLE_DETAIL.info, workOrderType: 6 }; + + it("derives mediaCount from stored media when detail info omits it", () => { + const detail = mapWorkOrderBoardDetail({ ...SAMPLE_DETAIL, info: reactiveInfo }); + + expect(detail.info.type).toBe("Reactive"); + expect(detail.info.mediaCount).toBe(2); + expect(getClosabilityGaps(tableRowToClosabilityInput(detail.info))).not.toContain( + "At least 1 photo", + ); + }); + + it("prefers stored media when detail info reports an explicit zero", () => { + const detail = mapWorkOrderBoardDetail({ + ...SAMPLE_DETAIL, + info: { ...reactiveInfo, mediaCount: 0 }, + }); + + expect(detail.info.mediaCount).toBe(2); + expect(getClosabilityGaps(tableRowToClosabilityInput(detail.info))).not.toContain( + "At least 1 photo", + ); + }); + + it("does not count legacy URL-column media, matching the board row count", () => { + const detail = mapWorkOrderBoardDetail({ + ...SAMPLE_DETAIL, + info: reactiveInfo, + media: [ + { + id: -1, + category: 1, + url: "https://cdn.example.com/legacy.jpg", + fileName: "legacy.jpg", + isLegacy: true, + }, + ], + }); + + expect(detail.info.mediaCount).toBe(0); + }); + + it("keeps an explicit info mediaCount", () => { + const detail = mapWorkOrderBoardDetail({ + ...SAMPLE_DETAIL, + info: { ...reactiveInfo, mediaCount: 5 }, + }); + + expect(detail.info.mediaCount).toBe(5); + }); +});