mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
chore(work-orders): sync wizard create onto slide-over media review fixes
This commit is contained in:
commit
a7bce3bd48
17 changed files with 470 additions and 83 deletions
|
|
@ -35,7 +35,9 @@ export function CompDocSidebar({
|
||||||
onNotesChange,
|
onNotesChange,
|
||||||
onDateCompletedChange,
|
onDateCompletedChange,
|
||||||
}: CompDocSidebarProps) {
|
}: CompDocSidebarProps) {
|
||||||
const cfg = DOC_STATE_CFG[docState];
|
const displayState: DocState =
|
||||||
|
isGenerated && docState !== "not-required" ? "generated" : docState;
|
||||||
|
const cfg = DOC_STATE_CFG[displayState];
|
||||||
const StateIcon = cfg.icon;
|
const StateIcon = cfg.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -83,7 +85,7 @@ export function CompDocSidebar({
|
||||||
marginTop: 3,
|
marginTop: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{docStatusCardDescription(docState, generatedAt)}
|
{docStatusCardDescription(displayState, generatedAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { useEffect, useRef, type Dispatch, type SetStateAction } from "react";
|
||||||
|
import type { CompDocData, DocState } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { docStateOf } from "@/domain/work-orders/utils/completion-doc-state";
|
||||||
|
|
||||||
|
type UseCompDocDialogSeedArgs = {
|
||||||
|
open: boolean;
|
||||||
|
infoId: string | number;
|
||||||
|
infoDocStatus: CompletionDocStatus;
|
||||||
|
infoCompletedDate?: string | null;
|
||||||
|
completionDocStatus?: CompletionDocStatus | null;
|
||||||
|
data?: CompDocData;
|
||||||
|
setNotes: Dispatch<SetStateAction<string>>;
|
||||||
|
setDocState: Dispatch<SetStateAction<DocState>>;
|
||||||
|
setGeneratedAt: Dispatch<SetStateAction<string>>;
|
||||||
|
setEditDateCompleted: Dispatch<SetStateAction<string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Prototype seeds once per open/WO session; detail refetches must not wipe edits. */
|
||||||
|
export function useCompDocDialogSeed({
|
||||||
|
open,
|
||||||
|
infoId,
|
||||||
|
infoDocStatus,
|
||||||
|
infoCompletedDate,
|
||||||
|
completionDocStatus,
|
||||||
|
data,
|
||||||
|
setNotes,
|
||||||
|
setDocState,
|
||||||
|
setGeneratedAt,
|
||||||
|
setEditDateCompleted,
|
||||||
|
}: UseCompDocDialogSeedArgs) {
|
||||||
|
const seedKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
seedKeyRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = String(infoId);
|
||||||
|
if (seedKeyRef.current === key) return;
|
||||||
|
seedKeyRef.current = key;
|
||||||
|
setNotes(data?.notes ?? "");
|
||||||
|
setDocState(docStateOf(completionDocStatus || infoDocStatus));
|
||||||
|
setGeneratedAt(data?.generatedAt ?? "");
|
||||||
|
setEditDateCompleted(infoCompletedDate || "");
|
||||||
|
}, [
|
||||||
|
open,
|
||||||
|
infoId,
|
||||||
|
infoDocStatus,
|
||||||
|
infoCompletedDate,
|
||||||
|
completionDocStatus,
|
||||||
|
data?.notes,
|
||||||
|
data?.generatedAt,
|
||||||
|
setNotes,
|
||||||
|
setDocState,
|
||||||
|
setGeneratedAt,
|
||||||
|
setEditDateCompleted,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -11,8 +11,10 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
|
||||||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
|
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
|
||||||
import { DOC_STATE_CFG, docStateOf } from "@/domain/work-orders/utils/completion-doc-state";
|
import { DOC_STATE_CFG } from "@/domain/work-orders/utils/completion-doc-state";
|
||||||
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
import { useCompDocDialogSeed } from "./use-comp-doc-dialog-seed";
|
||||||
|
import { useCompDocNotRequiredActions } from "./use-comp-doc-not-required-actions";
|
||||||
import { useCompDocPdfActions } from "./use-comp-doc-pdf-actions";
|
import { useCompDocPdfActions } from "./use-comp-doc-pdf-actions";
|
||||||
import { useCompDocPersistFlow } from "./use-comp-doc-persist-flow";
|
import { useCompDocPersistFlow } from "./use-comp-doc-persist-flow";
|
||||||
|
|
||||||
|
|
@ -71,45 +73,31 @@ export function useCompDocDialog({
|
||||||
const signOffAttachment = completion?.signOffAttachment;
|
const signOffAttachment = completion?.signOffAttachment;
|
||||||
const rowVersion = info.rowVersion || row.rowVersion;
|
const rowVersion = info.rowVersion || row.rowVersion;
|
||||||
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(info.status);
|
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(info.status);
|
||||||
|
|
||||||
const site = useMemo(() => resolveCompDocSite(info, locationDetail), [info, locationDetail]);
|
const site = useMemo(() => resolveCompDocSite(info, locationDetail), [info, locationDetail]);
|
||||||
|
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [docState, setDocState] = useState<DocState>("draft");
|
const [docState, setDocState] = useState<DocState>("draft");
|
||||||
const [generatedAt, setGeneratedAt] = useState("");
|
const [generatedAt, setGeneratedAt] = useState("");
|
||||||
const [editDateCompleted, setEditDateCompleted] = useState("");
|
const [editDateCompleted, setEditDateCompleted] = useState("");
|
||||||
const seedKeyRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
// Prototype seeds once per open/WO session. Detail refetches must not wipe
|
useCompDocDialogSeed({
|
||||||
// in-progress PM comments or date edits (Bugbot: dialog effect clears edits).
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
seedKeyRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const key = String(info.id);
|
|
||||||
if (seedKeyRef.current === key) return;
|
|
||||||
seedKeyRef.current = key;
|
|
||||||
setNotes(data?.notes ?? "");
|
|
||||||
setDocState(docStateOf(completion?.docStatus || info.docStatus));
|
|
||||||
setGeneratedAt(data?.generatedAt ?? "");
|
|
||||||
setEditDateCompleted(info.completedDate || "");
|
|
||||||
}, [
|
|
||||||
open,
|
open,
|
||||||
info.id,
|
infoId: info.id,
|
||||||
info.docStatus,
|
infoDocStatus: info.docStatus,
|
||||||
info.completedDate,
|
infoCompletedDate: info.completedDate,
|
||||||
completion?.docStatus,
|
completionDocStatus: completion?.docStatus,
|
||||||
data?.notes,
|
data,
|
||||||
data?.generatedAt,
|
setNotes,
|
||||||
]);
|
setDocState,
|
||||||
|
setGeneratedAt,
|
||||||
|
setEditDateCompleted,
|
||||||
|
});
|
||||||
|
|
||||||
const amazonRep = info.pocName || "";
|
const amazonRep = info.pocName || "";
|
||||||
const seaRep = info.tech || "";
|
const seaRep = info.tech || "";
|
||||||
const cfg = DOC_STATE_CFG[docState];
|
const cfg = DOC_STATE_CFG[docState];
|
||||||
const isNotRequired = docState === "not-required";
|
const isNotRequired = docState === "not-required";
|
||||||
const isGenerated = docState === "generated" || Boolean(signOffAttachment);
|
const isGenerated = docState === "generated" || Boolean(signOffAttachment);
|
||||||
const isBusy = uploadCompletionMutation.isPending || isFinalizing;
|
|
||||||
|
|
||||||
const docFields: DocFields = {
|
const docFields: DocFields = {
|
||||||
street: site.street,
|
street: site.street,
|
||||||
|
|
@ -137,37 +125,13 @@ export function useCompDocDialog({
|
||||||
onDraftSaved: notifyDraftSaved,
|
onDraftSaved: notifyDraftSaved,
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleMarkNotRequired() {
|
const { handleMarkNotRequired, handleUndo } = useCompDocNotRequiredActions({
|
||||||
const previous = docState;
|
docState,
|
||||||
setDocState("not-required");
|
setDocState,
|
||||||
setIsFinalizing(true);
|
persist,
|
||||||
try {
|
refreshAuthoritativeState,
|
||||||
await persist({}, "NN");
|
setIsFinalizing,
|
||||||
} catch (error) {
|
});
|
||||||
setDocState(previous);
|
|
||||||
refreshAuthoritativeState();
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error ? error.message : "Failed to mark document as not required",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsFinalizing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUndo() {
|
|
||||||
const previous = docState;
|
|
||||||
setDocState("draft");
|
|
||||||
setIsFinalizing(true);
|
|
||||||
try {
|
|
||||||
await persist({}, "No");
|
|
||||||
} catch (error) {
|
|
||||||
setDocState(previous);
|
|
||||||
refreshAuthoritativeState();
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to undo not-required");
|
|
||||||
} finally {
|
|
||||||
setIsFinalizing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({
|
const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({
|
||||||
info,
|
info,
|
||||||
|
|
@ -203,7 +167,7 @@ export function useCompDocDialog({
|
||||||
cfg,
|
cfg,
|
||||||
isNotRequired,
|
isNotRequired,
|
||||||
isGenerated,
|
isGenerated,
|
||||||
isUploading: isBusy,
|
isUploading: uploadCompletionMutation.isPending || isFinalizing,
|
||||||
pdfInputRef,
|
pdfInputRef,
|
||||||
handleMarkNotRequired,
|
handleMarkNotRequired,
|
||||||
handleUndo,
|
handleUndo,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import type {
|
||||||
|
CompDocData,
|
||||||
|
CompDocSaveStatus,
|
||||||
|
DocState,
|
||||||
|
} from "@/domain/work-orders/types/completion-doc";
|
||||||
|
|
||||||
|
type PersistFn = (data: Partial<CompDocData>, status: CompDocSaveStatus) => void | Promise<void>;
|
||||||
|
|
||||||
|
type UseCompDocNotRequiredActionsArgs = {
|
||||||
|
docState: DocState;
|
||||||
|
setDocState: (state: DocState) => void;
|
||||||
|
persist: PersistFn;
|
||||||
|
refreshAuthoritativeState: () => void;
|
||||||
|
setIsFinalizing: (value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCompDocNotRequiredActions({
|
||||||
|
docState,
|
||||||
|
setDocState,
|
||||||
|
persist,
|
||||||
|
refreshAuthoritativeState,
|
||||||
|
setIsFinalizing,
|
||||||
|
}: UseCompDocNotRequiredActionsArgs) {
|
||||||
|
async function handleMarkNotRequired() {
|
||||||
|
const previous = docState;
|
||||||
|
setDocState("not-required");
|
||||||
|
setIsFinalizing(true);
|
||||||
|
try {
|
||||||
|
await persist({}, "NN");
|
||||||
|
} catch (error) {
|
||||||
|
setDocState(previous);
|
||||||
|
refreshAuthoritativeState();
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Failed to mark document as not required",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsFinalizing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUndo() {
|
||||||
|
const previous = docState;
|
||||||
|
setDocState("draft");
|
||||||
|
setIsFinalizing(true);
|
||||||
|
try {
|
||||||
|
await persist({}, "No");
|
||||||
|
} catch (error) {
|
||||||
|
setDocState(previous);
|
||||||
|
refreshAuthoritativeState();
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to undo not-required");
|
||||||
|
} finally {
|
||||||
|
setIsFinalizing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handleMarkNotRequired, handleUndo };
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@ export function useSlideOverEditState({
|
||||||
/** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
|
/** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
|
||||||
const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null);
|
const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [showUnsaved, setShowUnsaved] = useState(false);
|
const [showUnsaved, setShowUnsaved] = useState(false);
|
||||||
|
const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null);
|
||||||
|
|
||||||
// Reset draft/baseline only when the opened WO identity or editMode intent changes —
|
// Reset draft/baseline only when the opened WO identity or editMode intent changes —
|
||||||
// not on every optimistic parent-row field update during a failed/in-flight save.
|
// not on every optimistic parent-row field update during a failed/in-flight save.
|
||||||
|
|
@ -43,6 +44,7 @@ export function useSlideOverEditState({
|
||||||
setBaseline(null);
|
setBaseline(null);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setShowUnsaved(false);
|
setShowUnsaved(false);
|
||||||
|
setUnsavedIntent(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDraft(row);
|
setDraft(row);
|
||||||
|
|
@ -50,6 +52,7 @@ export function useSlideOverEditState({
|
||||||
setEditing(editMode && (row.status as string) !== "Canceled");
|
setEditing(editMode && (row.status as string) !== "Canceled");
|
||||||
setTab("info");
|
setTab("info");
|
||||||
setShowUnsaved(false);
|
setShowUnsaved(false);
|
||||||
|
setUnsavedIntent(null);
|
||||||
// Intentionally identity-only: depending on `row` would absorb optimistic patches.
|
// Intentionally identity-only: depending on `row` would absorb optimistic patches.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
|
||||||
}, [row?.id, editMode]);
|
}, [row?.id, editMode]);
|
||||||
|
|
@ -106,6 +109,7 @@ export function useSlideOverEditState({
|
||||||
const requestClose = () => {
|
const requestClose = () => {
|
||||||
if (closeDisabled) return;
|
if (closeDisabled) return;
|
||||||
if (editing && isDirty) {
|
if (editing && isDirty) {
|
||||||
|
setUnsavedIntent("close");
|
||||||
setShowUnsaved(true);
|
setShowUnsaved(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -114,6 +118,7 @@ export function useSlideOverEditState({
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
const handleCancelEdit = () => {
|
||||||
if (isDirty) {
|
if (isDirty) {
|
||||||
|
setUnsavedIntent("cancel-edit");
|
||||||
setShowUnsaved(true);
|
setShowUnsaved(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -121,6 +126,17 @@ export function useSlideOverEditState({
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDiscard = () => {
|
||||||
|
const intent = unsavedIntent;
|
||||||
|
discard();
|
||||||
|
setUnsavedIntent(null);
|
||||||
|
// Close only when Discard was opened from panel close — never from cancel-edit.
|
||||||
|
// Skip when uploads block close (closeDisabled).
|
||||||
|
if (intent === "close" && !closeDisabled) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const startEditing = () => {
|
const startEditing = () => {
|
||||||
if (!infoSource) return;
|
if (!infoSource) return;
|
||||||
setDraft(infoSource);
|
setDraft(infoSource);
|
||||||
|
|
@ -138,6 +154,7 @@ export function useSlideOverEditState({
|
||||||
handleDraftChange,
|
handleDraftChange,
|
||||||
commit,
|
commit,
|
||||||
discard,
|
discard,
|
||||||
|
handleDiscard,
|
||||||
requestClose,
|
requestClose,
|
||||||
handleCancelEdit,
|
handleCancelEdit,
|
||||||
startEditing,
|
startEditing,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import type {
|
||||||
useUploadWorkOrderMedia,
|
useUploadWorkOrderMedia,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
|
|
||||||
const COMPLETION_MEDIA_CATEGORIES = new Set(["Before", "After", "Completion"]);
|
const COMPLETION_MEDIA_PURPOSES = new Set(["Before", "After", "Completion"]);
|
||||||
|
|
||||||
type UseSlideOverMediaStateArgs = {
|
type UseSlideOverMediaStateArgs = {
|
||||||
detailForRow?: WorkOrderBoardDetail;
|
detailForRow?: WorkOrderBoardDetail;
|
||||||
|
|
@ -33,7 +33,7 @@ export function useSlideOverMediaState({
|
||||||
() =>
|
() =>
|
||||||
uploads.filter(
|
uploads.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
COMPLETION_MEDIA_CATEGORIES.has(item.category) ||
|
COMPLETION_MEDIA_PURPOSES.has(item.purpose ?? "") ||
|
||||||
item.uploadStatus === "uploading" ||
|
item.uploadStatus === "uploading" ||
|
||||||
item.uploadStatus === "failed",
|
item.uploadStatus === "failed",
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -92,11 +92,23 @@ export function useWorkOrderSlideOver({
|
||||||
|
|
||||||
const uploadCompletionPdf = (file: File) => {
|
const uploadCompletionPdf = (file: File) => {
|
||||||
const version = infoSource?.rowVersion;
|
const version = infoSource?.rowVersion;
|
||||||
if (!version) {
|
if (!version || !row || !infoSource) {
|
||||||
toast.error("Missing work order version. Refresh and try again.");
|
toast.error("Missing work order version. Refresh and try again.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
uploadCompletionMutation.mutate({ file, workOrderVersion: version });
|
uploadCompletionMutation.mutate(
|
||||||
|
{ file, workOrderVersion: version },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
// Mirror CompDoc finalize: ensure completedDate then mark docStatus Yes.
|
||||||
|
const patch: WorkOrderTablePatch = { docStatus: "Yes" };
|
||||||
|
if (!infoSource.completedDate) {
|
||||||
|
patch.completedDate = new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
onSave(row.id, patch);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const showDetailSkeleton = isLoading && !detailForRow;
|
const showDetailSkeleton = isLoading && !detailForRow;
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ export function WorkOrderSlideOverTabs({
|
||||||
{state.detailForRow != null && (
|
{state.detailForRow != null && (
|
||||||
<SlideOverCommentsTab
|
<SlideOverCommentsTab
|
||||||
comments={state.detailForRow.comments}
|
comments={state.detailForRow.comments}
|
||||||
isReadOnly={state.isCancelled}
|
isReadOnly={state.isDocumentsReadOnly}
|
||||||
isPending={state.commentMutation.isPending}
|
isPending={state.commentMutation.isPending}
|
||||||
currentUserId={state.user?.id}
|
currentUserId={state.user?.id}
|
||||||
isUpdatePending={state.updateCommentMutation.isPending}
|
isUpdatePending={state.updateCommentMutation.isPending}
|
||||||
|
|
|
||||||
|
|
@ -98,10 +98,7 @@ export function WorkOrderSlideOver({
|
||||||
onClose={state.requestClose}
|
onClose={state.requestClose}
|
||||||
onEdit={state.startEditing}
|
onEdit={state.startEditing}
|
||||||
onSave={state.commit}
|
onSave={state.commit}
|
||||||
onDiscard={() => {
|
onDiscard={state.handleDiscard}
|
||||||
state.discard();
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
onKeepEditing={() => state.setShowUnsaved(false)}
|
onKeepEditing={() => state.setShowUnsaved(false)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||||
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
|
@ -39,12 +39,40 @@ function isSameAttempt(item: LocalUpload, request: SessionUploadRequest) {
|
||||||
return item.id === request.localId && item.signal === request.signal;
|
return item.id === request.localId && item.signal === request.signal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function revokeBlobUrl(url: string) {
|
||||||
|
if (url.startsWith("blob:")) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useMediaUploadSession(
|
export function useMediaUploadSession(
|
||||||
detail: WorkOrderBoardDetail | undefined,
|
detail: WorkOrderBoardDetail | undefined,
|
||||||
uploadMutateAsync: UploadMutate,
|
uploadMutateAsync: UploadMutate,
|
||||||
) {
|
) {
|
||||||
const [localUploads, setLocalUploads] = useState<LocalUpload[]>([]);
|
const [localUploads, setLocalUploads] = useState<LocalUpload[]>([]);
|
||||||
const controllersRef = useRef(new Map<string, AbortController>());
|
const controllersRef = useRef(new Map<string, AbortController>());
|
||||||
|
const workOrderId = detail?.info.id;
|
||||||
|
const previousWorkOrderIdRef = useRef(workOrderId);
|
||||||
|
|
||||||
|
// Drop local tiles and abort in-flight uploads when the bound WO changes.
|
||||||
|
// Skip the first bind (undefined → id) so an early upload is not cleared.
|
||||||
|
useEffect(() => {
|
||||||
|
if (previousWorkOrderIdRef.current === workOrderId) return;
|
||||||
|
const hadPrevious = previousWorkOrderIdRef.current != null;
|
||||||
|
previousWorkOrderIdRef.current = workOrderId;
|
||||||
|
if (!hadPrevious) return;
|
||||||
|
|
||||||
|
for (const controller of controllersRef.current.values()) {
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
controllersRef.current.clear();
|
||||||
|
setLocalUploads((prev) => {
|
||||||
|
for (const item of prev) {
|
||||||
|
revokeBlobUrl(item.url);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}, [workOrderId]);
|
||||||
|
|
||||||
const serverUploads = useMemo(
|
const serverUploads = useMemo(
|
||||||
() => boardMediaItemsToMediaFiles(detail?.media ?? []),
|
() => boardMediaItemsToMediaFiles(detail?.media ?? []),
|
||||||
|
|
@ -84,8 +112,8 @@ export function useMediaUploadSession(
|
||||||
const index = next.findIndex((item) => item.id === request.localId);
|
const index = next.findIndex((item) => item.id === request.localId);
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
const previous = next[index];
|
const previous = next[index];
|
||||||
if (previous?.url.startsWith("blob:")) {
|
if (previous) {
|
||||||
URL.revokeObjectURL(previous.url);
|
revokeBlobUrl(previous.url);
|
||||||
}
|
}
|
||||||
next[index] = entry;
|
next[index] = entry;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -151,8 +179,8 @@ export function useMediaUploadSession(
|
||||||
clearController(localId);
|
clearController(localId);
|
||||||
setLocalUploads((prev) => {
|
setLocalUploads((prev) => {
|
||||||
const target = prev.find((item) => item.id === localId);
|
const target = prev.find((item) => item.id === localId);
|
||||||
if (target?.url.startsWith("blob:")) {
|
if (target) {
|
||||||
URL.revokeObjectURL(target.url);
|
revokeBlobUrl(target.url);
|
||||||
}
|
}
|
||||||
return prev.filter((item) => item.id !== localId);
|
return prev.filter((item) => item.id !== localId);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -231,10 +231,7 @@ export function boardMediaItemsToMediaFiles(items: WorkOrderBoardMediaItem[]): M
|
||||||
fileType: classifyFileName(item.fileName, item.url),
|
fileType: classifyFileName(item.fileName, item.url),
|
||||||
url: item.url,
|
url: item.url,
|
||||||
category: boardCategoryToMediaCategory(item.category),
|
category: boardCategoryToMediaCategory(item.category),
|
||||||
purpose:
|
purpose: item.category,
|
||||||
item.category === "Before" || item.category === "After" || item.category === "Extra"
|
|
||||||
? item.category
|
|
||||||
: null,
|
|
||||||
isLegacy: item.isLegacy || Number(item.id) <= 0,
|
isLegacy: item.isLegacy || Number(item.id) <= 0,
|
||||||
uploadStatus: "done" as const,
|
uploadStatus: "done" as const,
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ export interface MediaFile {
|
||||||
fileType: FileCategory;
|
fileType: FileCategory;
|
||||||
url: string;
|
url: string;
|
||||||
category: MediaCategory;
|
category: MediaCategory;
|
||||||
/** Board purpose label (Before/After/Extra). Null/undefined = needs categorization. */
|
/** Board purpose label (Before/After/Extra/Completion). Null/undefined = needs categorization. */
|
||||||
purpose?: "Before" | "After" | "Extra" | null;
|
purpose?: "Before" | "After" | "Extra" | "Completion" | null;
|
||||||
/** True when media is a legacy Before/After/Completion column (not deletable). */
|
/** True when media is a legacy Before/After/Completion column (not deletable). */
|
||||||
isLegacy?: boolean;
|
isLegacy?: boolean;
|
||||||
/** 0–100 while uploading; undefined when idle/complete. */
|
/** 0–100 while uploading; undefined when idle/complete. */
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,7 @@ export const SLIDE_OVER_EDIT_KEYS = [
|
||||||
"serviceNotes",
|
"serviceNotes",
|
||||||
"extraServices",
|
"extraServices",
|
||||||
"vendorId",
|
"vendorId",
|
||||||
"company",
|
// company/tech are display companions; board persists vendor via vendorId only
|
||||||
"tech",
|
|
||||||
"status",
|
"status",
|
||||||
] as const satisfies ReadonlyArray<keyof WorkOrderTableRow>;
|
] as const satisfies ReadonlyArray<keyof WorkOrderTableRow>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -132,4 +132,75 @@ describe("useMediaUploadSession retry / cancel", () => {
|
||||||
expect(result.current.uploads).toHaveLength(0);
|
expect(result.current.uploads).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears local uploads and aborts in-flight work when the bound WO changes", async () => {
|
||||||
|
const uploadMutateAsync = vi.fn().mockImplementation(
|
||||||
|
({ signal }: { signal?: AbortSignal }) =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
signal?.addEventListener("abort", () => {
|
||||||
|
const error = new Error("aborted");
|
||||||
|
error.name = "AbortError";
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const detailA = {
|
||||||
|
info: { id: 1 },
|
||||||
|
media: [],
|
||||||
|
comments: [],
|
||||||
|
audit: [],
|
||||||
|
completion: {
|
||||||
|
docStatus: "",
|
||||||
|
template: null,
|
||||||
|
signOffName: "",
|
||||||
|
signOffAttachment: "",
|
||||||
|
signOffSignature: "",
|
||||||
|
dispatchSignoffs: [],
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof useMediaUploadSession>[0];
|
||||||
|
|
||||||
|
const detailB = {
|
||||||
|
info: { id: 2 },
|
||||||
|
media: [],
|
||||||
|
comments: [],
|
||||||
|
audit: [],
|
||||||
|
completion: {
|
||||||
|
docStatus: "",
|
||||||
|
template: null,
|
||||||
|
signOffName: "",
|
||||||
|
signOffAttachment: "",
|
||||||
|
signOffSignature: "",
|
||||||
|
dispatchSignoffs: [],
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof useMediaUploadSession>[0];
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ detail }) => useMediaUploadSession(detail, uploadMutateAsync),
|
||||||
|
{ initialProps: { detail: detailA } },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.startUploads([
|
||||||
|
{
|
||||||
|
localId: "local-wo-a",
|
||||||
|
file: makeFile("a.jpg"),
|
||||||
|
onProgress: () => undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.uploads).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstSignal = uploadMutateAsync.mock.calls[0]?.[0]?.signal as AbortSignal;
|
||||||
|
|
||||||
|
rerender({ detail: detailB });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.uploads).toHaveLength(0);
|
||||||
|
});
|
||||||
|
expect(firstSignal.aborted).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { useSlideOverMediaState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-media-state";
|
||||||
|
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
|
return {
|
||||||
|
id: 100,
|
||||||
|
woNumber: "100",
|
||||||
|
site: "SITE",
|
||||||
|
type: "Emergency",
|
||||||
|
dispatcherId: "d1",
|
||||||
|
dispatcherName: "Alex",
|
||||||
|
scheduledOn: "2026-07-16",
|
||||||
|
completedDate: "",
|
||||||
|
targetWeek: "",
|
||||||
|
dayGroup: "2026-07-16",
|
||||||
|
dayLabel: "Thu",
|
||||||
|
location: "Main",
|
||||||
|
locationId: 5,
|
||||||
|
pm: "",
|
||||||
|
serviceNotes: "",
|
||||||
|
extraServices: [],
|
||||||
|
vendorId: "v1",
|
||||||
|
company: "Acme",
|
||||||
|
tech: "Sam",
|
||||||
|
techPhone: "",
|
||||||
|
apptStart: "",
|
||||||
|
apptEnd: "",
|
||||||
|
apptTime: "",
|
||||||
|
status: "Incomplete",
|
||||||
|
docStatus: "",
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
pocNotes: "",
|
||||||
|
dueDate: "",
|
||||||
|
rescheduleCount: 0,
|
||||||
|
carriedOver: 0,
|
||||||
|
originalWeek: "",
|
||||||
|
originalDate: "",
|
||||||
|
isPastDue: false,
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
mediaCount: 0,
|
||||||
|
title: "",
|
||||||
|
priority: "",
|
||||||
|
primaryDispatchId: null,
|
||||||
|
rowVersion: "1",
|
||||||
|
dispatchRowVersion: "",
|
||||||
|
flagColor: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailWithMedia(
|
||||||
|
media: WorkOrderBoardDetail["media"],
|
||||||
|
infoOverrides: Partial<WorkOrderTableRow> = {},
|
||||||
|
): WorkOrderBoardDetail {
|
||||||
|
return {
|
||||||
|
info: baseInfo(infoOverrides),
|
||||||
|
completion: {
|
||||||
|
docStatus: "",
|
||||||
|
template: null,
|
||||||
|
signOffName: "",
|
||||||
|
signOffAttachment: "",
|
||||||
|
signOffSignature: "",
|
||||||
|
dispatchSignoffs: [],
|
||||||
|
},
|
||||||
|
comments: [],
|
||||||
|
audit: [],
|
||||||
|
media,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function idleMutation() {
|
||||||
|
return {
|
||||||
|
mutate: vi.fn(),
|
||||||
|
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||||
|
isPending: false,
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useSlideOverMediaState completion filter", () => {
|
||||||
|
it("keeps Before/After/Completion after refetch mapped purpose (not file category)", () => {
|
||||||
|
const detail = detailWithMedia([
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
category: "Before",
|
||||||
|
url: "https://cdn.example.com/before.jpg",
|
||||||
|
fileName: "before.jpg",
|
||||||
|
uploadedAt: "2026-07-15T18:00:00.0000000Z",
|
||||||
|
isLegacy: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
category: "After",
|
||||||
|
url: "https://cdn.example.com/after.jpg",
|
||||||
|
fileName: "after.jpg",
|
||||||
|
uploadedAt: "2026-07-15T18:00:00.0000000Z",
|
||||||
|
isLegacy: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
category: "Completion",
|
||||||
|
url: "https://cdn.example.com/completion.pdf",
|
||||||
|
fileName: "completion.pdf",
|
||||||
|
uploadedAt: "2026-07-15T18:00:00.0000000Z",
|
||||||
|
isLegacy: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
category: "Extra",
|
||||||
|
url: "https://cdn.example.com/extra.pdf",
|
||||||
|
fileName: "extra.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.completionMedia.map((item) => item.purpose)).toEqual([
|
||||||
|
"Before",
|
||||||
|
"After",
|
||||||
|
"Completion",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
result.current.completionMedia.every((item) =>
|
||||||
|
["Photo", "Video", "Report", "Other"].includes(item.category),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(result.current.extrasUploads).toHaveLength(1);
|
||||||
|
expect(result.current.extrasUploads[0]?.purpose).toBe("Extra");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -170,13 +170,40 @@ describe("mapWorkOrderBoardDetail", () => {
|
||||||
name: "before.jpg",
|
name: "before.jpg",
|
||||||
url: "https://cdn.example.com/before.jpg",
|
url: "https://cdn.example.com/before.jpg",
|
||||||
category: "Photo",
|
category: "Photo",
|
||||||
|
purpose: "Before",
|
||||||
fileType: "image",
|
fileType: "image",
|
||||||
isLegacy: false,
|
isLegacy: false,
|
||||||
});
|
});
|
||||||
expect(files[1]?.category).toBe("Other");
|
expect(files[1]?.category).toBe("Other");
|
||||||
|
expect(files[1]?.purpose).toBe("Extra");
|
||||||
expect(files[1]?.fileType).toBe("pdf");
|
expect(files[1]?.fileType).toBe("pdf");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps After and Completion board categories to purpose for completion UI", () => {
|
||||||
|
const after = mapBoardDetailMediaItem({
|
||||||
|
id: 20,
|
||||||
|
category: 2,
|
||||||
|
url: "https://cdn.example.com/after.jpg",
|
||||||
|
fileName: "after.jpg",
|
||||||
|
});
|
||||||
|
const completion = mapBoardDetailMediaItem({
|
||||||
|
id: 21,
|
||||||
|
category: 4,
|
||||||
|
url: "https://cdn.example.com/completion.pdf",
|
||||||
|
fileName: "completion.pdf",
|
||||||
|
});
|
||||||
|
const files = boardMediaItemsToMediaFiles([after, completion]);
|
||||||
|
expect(files[0]).toMatchObject({
|
||||||
|
category: "Photo",
|
||||||
|
purpose: "After",
|
||||||
|
});
|
||||||
|
expect(files[1]).toMatchObject({
|
||||||
|
category: "Report",
|
||||||
|
purpose: "Completion",
|
||||||
|
fileType: "pdf",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("maps numeric category and marks negative ids as legacy", () => {
|
it("maps numeric category and marks negative ids as legacy", () => {
|
||||||
const legacy = mapBoardDetailMediaItem({
|
const legacy = mapBoardDetailMediaItem({
|
||||||
id: -1,
|
id: -1,
|
||||||
|
|
|
||||||
|
|
@ -58,9 +58,23 @@ describe("slide-over-draft", () => {
|
||||||
it("exposes only board-persistable edit keys", () => {
|
it("exposes only board-persistable edit keys", () => {
|
||||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("woNumber");
|
expect(SLIDE_OVER_EDIT_KEYS).toContain("woNumber");
|
||||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("status");
|
expect(SLIDE_OVER_EDIT_KEYS).toContain("status");
|
||||||
|
expect(SLIDE_OVER_EDIT_KEYS).toContain("vendorId");
|
||||||
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("pocName");
|
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("pocName");
|
||||||
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId");
|
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId");
|
||||||
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("techPhone");
|
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("techPhone");
|
||||||
|
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("company");
|
||||||
|
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("tech");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks vendor via vendorId only (company/tech are display companions)", () => {
|
||||||
|
const row = baseRow();
|
||||||
|
const draft = baseRow({ company: "Other Co", tech: "Pat" });
|
||||||
|
expect(isSlideOverDraftDirty(draft, row)).toBe(false);
|
||||||
|
expect(buildSlideOverPatch(draft, row)).toEqual({});
|
||||||
|
|
||||||
|
const vendorDraft = baseRow({ vendorId: "v2", company: "Other Co", tech: "Pat" });
|
||||||
|
expect(isSlideOverDraftDirty(vendorDraft, row)).toBe(true);
|
||||||
|
expect(buildSlideOverPatch(vendorDraft, row)).toEqual({ vendorId: "v2" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores POC-only edits for dirty state", () => {
|
it("ignores POC-only edits for dirty state", () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue