mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 06:59:01 +00:00
The detail endpoint's info inherits MediaCount from the board row DTO but the service never sets it, so it serializes as 0. The previous guard trusted any numeric value and returned that 0, leaving the slide-over completion gate reporting "At least 1 photo" even after an upload. Take the larger of the reported count and the stored (non-legacy) attachments so a positive API count still wins while a spurious or omitted 0 no longer masks present media. Dropping the unused mapped-row argument also removes a flat-payload path that would have counted legacy items.
309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
import { DOC_STATUS_API } from "@/domain/work-orders/types/work-order-board";
|
|
import type {
|
|
BoardMediaCategory,
|
|
WorkOrderBoardCompletion,
|
|
WorkOrderBoardDetail,
|
|
WorkOrderBoardMediaItem,
|
|
} from "@/domain/work-orders/types/work-order-board-detail";
|
|
import { BOARD_MEDIA_CATEGORY_API } from "@/domain/work-orders/types/work-order-board-detail";
|
|
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
|
|
import type {
|
|
WorkOrderAuditEntry,
|
|
WorkOrderComment,
|
|
} from "@/domain/work-orders/types/work-order-detail";
|
|
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
|
|
import { toDisplayFileName } from "@/domain/work-orders/utils/display-file-name";
|
|
import type {
|
|
FileCategory,
|
|
MediaCategory,
|
|
MediaFile,
|
|
} from "@/domain/work-orders/types/work-order-media";
|
|
|
|
function asRecord(raw: unknown): Record<string, unknown> {
|
|
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
|
}
|
|
|
|
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
|
for (const key of keys) {
|
|
const value = record[key];
|
|
if (typeof value === "string") {
|
|
return value;
|
|
}
|
|
if (typeof value === "number" || typeof value === "boolean") {
|
|
return String(value);
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function readNumber(record: Record<string, unknown>, ...keys: string[]): number | null {
|
|
for (const key of keys) {
|
|
const value = record[key];
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
if (typeof value === "string" && value.trim() !== "") {
|
|
const parsed = Number(value);
|
|
if (Number.isFinite(parsed)) {
|
|
return parsed;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function readBoolean(record: Record<string, unknown>, ...keys: string[]): boolean {
|
|
for (const key of keys) {
|
|
const value = record[key];
|
|
if (typeof value === "boolean") {
|
|
return value;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function readStringArray(record: Record<string, unknown>, ...keys: string[]): string[] {
|
|
for (const key of keys) {
|
|
const value = record[key];
|
|
if (!Array.isArray(value)) {
|
|
continue;
|
|
}
|
|
|
|
const mapped = value
|
|
.map((entry) => {
|
|
if (typeof entry === "string") {
|
|
return entry;
|
|
}
|
|
if (typeof entry === "number" || typeof entry === "boolean") {
|
|
return String(entry);
|
|
}
|
|
return "";
|
|
})
|
|
.filter(Boolean);
|
|
|
|
if (mapped.length > 0) {
|
|
return mapped;
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function mapDocStatus(raw: unknown): CompletionDocStatus {
|
|
const numeric = typeof raw === "number" ? raw : Number(raw);
|
|
if (numeric === DOC_STATUS_API.Yes) return "Yes";
|
|
if (numeric === DOC_STATUS_API.No) return "No";
|
|
if (numeric === DOC_STATUS_API.NotNeeded) return "NN";
|
|
if (raw === "Yes" || raw === "No" || raw === "NN") return raw;
|
|
if (raw === "NotNeeded") return "NN";
|
|
return "";
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
export function mapBoardDetailComment(raw: unknown): WorkOrderComment {
|
|
const item = asRecord(raw);
|
|
const id = readNumber(item, "id");
|
|
const authorId = readString(item, "authorId", "AuthorId") || null;
|
|
return {
|
|
id: id ?? (readString(item, "id") || `comment-${readString(item, "time")}`),
|
|
createdDate: readString(item, "time", "createdDate", "CreatedDate"),
|
|
commenttext: readString(item, "text", "commenttext", "Commenttext"),
|
|
commentType: readString(item, "commentType", "CommentType") || "internal",
|
|
userName: readString(item, "userName", "UserName", "authorName", "AuthorName"),
|
|
authorId,
|
|
commenter: readString(item, "commenter", "Commenter") || null,
|
|
recordType: readString(item, "recordType", "RecordType") || null,
|
|
mentions: readStringArray(item, "mentions", "Mentions"),
|
|
};
|
|
}
|
|
|
|
export function mapBoardDetailAuditEntry(raw: unknown, index: number): WorkOrderAuditEntry {
|
|
const item = asRecord(raw);
|
|
const time = readString(item, "time", "createdAt", "CreatedAt");
|
|
const typeRaw = readString(item, "type", "Type").toLowerCase();
|
|
const type: "manual" | "system" = typeRaw === "system" ? "system" : "manual";
|
|
const dispatcherId = readString(item, "dispatcherId", "DispatcherId");
|
|
const userName = readString(item, "userName", "UserName");
|
|
|
|
return {
|
|
id: `${time || "audit"}-${index}`,
|
|
fieldName: readString(item, "fieldName", "FieldName"),
|
|
oldValue: readString(item, "oldValue", "OldValue"),
|
|
newValue: readString(item, "newValue", "NewValue"),
|
|
action: readString(item, "action", "Action"),
|
|
createdAt: time,
|
|
userName: type === "system" ? "System" : userName,
|
|
type,
|
|
dispatcherId: dispatcherId || undefined,
|
|
};
|
|
}
|
|
|
|
export type UploadableMediaCategory = "Before" | "After" | "Extra" | "Aveta";
|
|
|
|
export function buildMediaFormData(file: File, category?: UploadableMediaCategory): FormData {
|
|
const form = new FormData();
|
|
if (category) {
|
|
form.append("category", category);
|
|
}
|
|
form.append("file", file);
|
|
return form;
|
|
}
|
|
|
|
export function buildCompletionDocFormData(params: {
|
|
file: File;
|
|
workOrderVersion: string;
|
|
signOffName?: string;
|
|
signOffSignature?: string;
|
|
}): FormData {
|
|
const form = new FormData();
|
|
form.append("file", params.file);
|
|
form.append("workOrderVersion", params.workOrderVersion);
|
|
if (params.signOffName) {
|
|
form.append("signOffName", params.signOffName);
|
|
}
|
|
if (params.signOffSignature) {
|
|
form.append("signOffSignature", params.signOffSignature);
|
|
}
|
|
return form;
|
|
}
|
|
|
|
export function mapCompletion(raw: unknown): WorkOrderBoardCompletion {
|
|
const item = asRecord(raw);
|
|
const templateRaw = item.template ?? item.Template;
|
|
const templateRecord = templateRaw != null ? asRecord(templateRaw) : null;
|
|
const signoffsRaw = item.dispatchSignoffs ?? item.DispatchSignoffs;
|
|
const signoffs = Array.isArray(signoffsRaw)
|
|
? signoffsRaw.map((entry) => {
|
|
const s = asRecord(entry);
|
|
return {
|
|
dispatchId: readNumber(s, "dispatchId", "DispatchId"),
|
|
signoffType: readString(s, "signoffType", "SignoffType"),
|
|
name: readString(s, "name", "Name"),
|
|
signedAt: readString(s, "signedAt", "SignedAt"),
|
|
};
|
|
})
|
|
: [];
|
|
|
|
return {
|
|
docStatus: mapDocStatus(item.docStatus ?? item.DocStatus),
|
|
template: templateRecord
|
|
? {
|
|
id: readNumber(templateRecord, "id", "Id") ?? 0,
|
|
name: readString(templateRecord, "name", "Name"),
|
|
serviceKey: readString(templateRecord, "serviceKey", "ServiceKey"),
|
|
workOrderType: readNumber(templateRecord, "workOrderType", "WorkOrderType"),
|
|
templateUrl: readString(templateRecord, "templateUrl", "TemplateUrl"),
|
|
isActive: readBoolean(templateRecord, "isActive", "IsActive"),
|
|
}
|
|
: null,
|
|
signOffName: readString(item, "signOffName", "SignOffName"),
|
|
signOffAttachment: readString(item, "signOffAttachment", "SignOffAttachment"),
|
|
signOffSignature: readString(item, "signOffSignature", "SignOffSignature"),
|
|
dispatchSignoffs: signoffs,
|
|
};
|
|
}
|
|
|
|
export function mapBoardDetailMediaItem(raw: unknown): WorkOrderBoardMediaItem {
|
|
const item = asRecord(raw);
|
|
const id = readNumber(item, "id");
|
|
const isLegacyFlag = readBoolean(item, "isLegacy", "IsLegacy");
|
|
return {
|
|
id: String(id ?? (readString(item, "id") || cryptoRandomId())),
|
|
category: mapMediaCategory(item.category ?? item.Category),
|
|
url: readString(item, "url", "Url"),
|
|
fileName: toDisplayFileName(readString(item, "fileName", "FileName") || "file"),
|
|
uploadedAt: readString(item, "uploadedAt", "UploadedAt"),
|
|
isLegacy: isLegacyFlag || (id != null && id <= 0),
|
|
};
|
|
}
|
|
|
|
function cryptoRandomId(): string {
|
|
return `media-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
}
|
|
|
|
function classifyFileName(fileName: string, url: string): FileCategory {
|
|
const name = `${fileName} ${url}`.toLowerCase();
|
|
if (/\.(jpe?g|png|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) {
|
|
return "image";
|
|
}
|
|
if (/\.(mp4|mov|webm|m4v)(\?|$)/i.test(name) || name.includes("video/")) {
|
|
return "video";
|
|
}
|
|
if (/\.pdf(\?|$)/i.test(name)) {
|
|
return "pdf";
|
|
}
|
|
if (/\.(docx?|xlsx?|pptx?)(\?|$)/i.test(name)) {
|
|
return "doc";
|
|
}
|
|
return "other";
|
|
}
|
|
|
|
function boardCategoryToMediaCategory(category: BoardMediaCategory): MediaCategory {
|
|
if (category === "Aveta") return "Aveta";
|
|
if (category === "Extra") return "Other";
|
|
if (category === "Completion") return "Report";
|
|
return "Photo";
|
|
}
|
|
|
|
/** Adapt board detail media items for existing MediaUploader / Extras UI. */
|
|
export function boardMediaItemsToMediaFiles(items: WorkOrderBoardMediaItem[]): MediaFile[] {
|
|
return items.map((item) => ({
|
|
id: item.id,
|
|
name: item.fileName,
|
|
fileType: classifyFileName(item.fileName, item.url),
|
|
url: item.url,
|
|
category: boardCategoryToMediaCategory(item.category),
|
|
purpose: item.category,
|
|
isLegacy: item.isLegacy || Number(item.id) <= 0,
|
|
uploadStatus: "done" as const,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* `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;
|
|
const commentsRaw = root.comments ?? root.Comments;
|
|
const auditRaw = root.audit ?? root.Audit;
|
|
const mediaRaw = root.media ?? root.Media;
|
|
const completionRaw = root.completion ?? root.Completion;
|
|
|
|
const comments = Array.isArray(commentsRaw) ? commentsRaw.map(mapBoardDetailComment) : [];
|
|
const audit = Array.isArray(auditRaw)
|
|
? auditRaw.map((entry, index) => mapBoardDetailAuditEntry(entry, index))
|
|
: [];
|
|
const media = Array.isArray(mediaRaw) ? mediaRaw.map(mapBoardDetailMediaItem) : [];
|
|
|
|
const info = mapWorkOrderTableRow(infoRaw);
|
|
|
|
return {
|
|
info: { ...info, mediaCount: resolveDetailMediaCount(infoRaw, media) },
|
|
completion: mapCompletion(completionRaw ?? {}),
|
|
comments,
|
|
audit,
|
|
media,
|
|
};
|
|
}
|