shoc-frontend-new/src/domain/work-orders/use-cases/use-work-order-mutations.ts
Arthur Bassi f35fce9bd5
feat(work-orders): comment @mentions autocomplete (SH-217) (#91)
* docs(work-orders): add pending BE contract checklist for design parity gaps

* feat(work-orders): add mentions in work-order comments composer

* docs(work-orders): align pr description with jira and qa residuals

* refactor(workorders): split work order view page into view components

* fix(work-orders): export comment assignment vendor input types for view hook

* fix(work-orders): drop preferredContact from vendor update payload

Align with WorkOrderVendorUpdatePayload on current dev so the mentions branch typechecks.

* fix(work-orders): remove Preferred Contact from WO vendor editor (SH-180)

EOF

* fix(work-orders): harden comment mentions and clear draft on success

* fix(work-orders): harden mention highlight boundary and submit catch [SH-217]

* fix(work-orders): keep mention cursor sync on current value [SH-217]

* fix(work-orders): keep mention menu open after typing @

Pass live text into syncCursor instead of a render-time valueRef.
Add a composer interaction regression for @ autocomplete visibility.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
2026-08-11 14:07:02 -03:00

409 lines
13 KiB
TypeScript

import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import { BoardPatchConflictError } from "@/domain/work-orders/errors/board-patch-errors";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
import type {
DispatchComment,
DispatchSignoff,
DispatchToVendorPayload,
UpdateDispatchPayload,
WorkOrderComment,
WorkOrderDetail,
WorkOrderFormValues,
WorkOrderVendorUpdatePayload,
} from "@/domain/work-orders/types/work-order";
import type {
WorkOrderBoardCompletion,
WorkOrderBoardMediaItem,
} from "@/domain/work-orders/types/work-order-board-detail";
import { queryKeys } from "@/infra/query-key/query-key";
function invalidateWorkOrders(queryClient: ReturnType<typeof useQueryClient>) {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
}
function invalidateBoardDetail(
queryClient: ReturnType<typeof useQueryClient>,
workOrderId: string | number,
) {
void queryClient.invalidateQueries({
queryKey: queryKeys.workOrders.boardDetail(workOrderId),
});
void queryClient.invalidateQueries({
queryKey: queryKeys.workOrders.boardMedia(workOrderId),
});
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
}
export interface AddWorkOrderCommentInput {
text: string;
mentions?: string[];
commentType?: "customer" | "internal";
}
export interface UpdateWorkOrderCommentInput {
commentId: string | number;
text: string;
}
export interface UploadWorkOrderMediaInput {
file: File;
category: UploadableMediaCategory;
}
export interface UploadCompletionDocInput {
file: File;
workOrderVersion: string;
signOffName?: string;
signOffSignature?: string;
}
export interface AddDispatchCommentInput {
text: string;
sendEmail: boolean;
}
export interface UpdateChecklistItemInput {
itemId: string | number;
isCompleted: boolean;
completedBy: string;
}
export interface AddDispatchSignoffInput {
signoffType: string;
name: string;
signature: string;
signatureMethod: string;
}
export interface ChangeWorkOrderAssignmentInput {
userId: string | number;
userName: string;
}
export interface UpdateWorkOrderVendorInput {
vendorId: string | number;
payload: WorkOrderVendorUpdatePayload;
}
export function useCreateWorkOrder(): UseMutationResult<
WorkOrderDetail,
Error,
WorkOrderFormSchemaValues | WorkOrderFormValues
> {
const queryClient = useQueryClient();
const navigate = useNavigate();
return useMutation({
mutationFn: (values: WorkOrderFormSchemaValues | WorkOrderFormValues) =>
workOrdersApi.create(values),
onSuccess: (data) => {
invalidateWorkOrders(queryClient);
toast.success("Work Order created successfully!");
navigate(`/workorders/${data.id}`);
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create work order");
},
});
}
export function useUpdateWorkOrder(
id: string | number,
): UseMutationResult<void, Error, WorkOrderFormSchemaValues | WorkOrderFormValues> {
const queryClient = useQueryClient();
const navigate = useNavigate();
return useMutation({
mutationFn: (values: WorkOrderFormSchemaValues | WorkOrderFormValues) =>
workOrdersApi.update(id, values),
onSuccess: () => {
invalidateWorkOrders(queryClient);
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(id) });
toast.success("Work Order updated successfully!");
navigate(`/workorders/${id}`);
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update work order");
},
});
}
export function useUpdateWorkOrderVendor(
workOrderId: string | number,
): UseMutationResult<void, Error, UpdateWorkOrderVendorInput> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ vendorId, payload }) =>
workOrdersApi.updateVendorFromWorkOrder(vendorId, payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
toast.success("Vendor contact updated");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update vendor contact");
},
});
}
export function useAddWorkOrderComment(
workOrderId: string | number,
): UseMutationResult<WorkOrderComment, Error, AddWorkOrderCommentInput> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ text, mentions }: AddWorkOrderCommentInput) =>
workOrdersApi.createComment(workOrderId, text, mentions),
onSuccess: () => {
invalidateBoardDetail(queryClient, workOrderId);
},
onError: (error: Error) => {
toast.error(error.message || "Failed to add comment");
},
});
}
export function useUpdateWorkOrderComment(
workOrderId: string | number,
): UseMutationResult<WorkOrderComment, Error, UpdateWorkOrderCommentInput> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ commentId, text }: UpdateWorkOrderCommentInput) =>
workOrdersApi.updateComment(workOrderId, commentId, text),
onSuccess: () => {
invalidateBoardDetail(queryClient, workOrderId);
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update comment");
},
});
}
export function useUploadWorkOrderMedia(
workOrderId: string | number,
): UseMutationResult<WorkOrderBoardMediaItem, Error, UploadWorkOrderMediaInput> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ file, category }: UploadWorkOrderMediaInput) =>
workOrdersApi.uploadMedia(workOrderId, file, category),
onSuccess: () => {
invalidateBoardDetail(queryClient, workOrderId);
invalidateWorkOrders(queryClient);
toast.success("File uploaded");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to upload file");
},
});
}
export function useDeleteWorkOrderMedia(
workOrderId: string | number,
): UseMutationResult<void, Error, string | number> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (mediaId: string | number) => workOrdersApi.deleteMedia(workOrderId, mediaId),
onSuccess: () => {
invalidateBoardDetail(queryClient, workOrderId);
invalidateWorkOrders(queryClient);
toast.success("File removed");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to remove file");
},
});
}
export function useUploadCompletionDoc(
workOrderId: string | number,
): UseMutationResult<WorkOrderBoardCompletion, Error, UploadCompletionDocInput> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (params: UploadCompletionDocInput) =>
workOrdersApi.uploadCompletionDoc(workOrderId, params),
onSuccess: () => {
invalidateBoardDetail(queryClient, workOrderId);
invalidateWorkOrders(queryClient);
toast.success("Completion document uploaded");
},
onError: (error: Error) => {
if (error instanceof BoardPatchConflictError) {
invalidateBoardDetail(queryClient, workOrderId);
toast.error("Document is out of date. Refresh and try again.");
return;
}
toast.error(error.message || "Failed to upload completion document");
},
});
}
export function useChangeWorkOrderStatus(
workOrderId: string | number,
): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (status: string) => workOrdersApi.changeStatus(workOrderId, status),
onSuccess: (_data, status) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
invalidateWorkOrders(queryClient);
toast.success(`Status changed to ${status}`);
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update status");
},
});
}
export function useChangeWorkOrderAssignment(
workOrderId: string | number,
): UseMutationResult<string, Error, ChangeWorkOrderAssignmentInput> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ userId, userName }: ChangeWorkOrderAssignmentInput) =>
workOrdersApi.changeAssignment(workOrderId, userId).then(() => userName),
onSuccess: (userName) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
invalidateWorkOrders(queryClient);
toast.success(`Assigned to ${userName || "Unassigned"}`);
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update assignment");
},
});
}
export function useDispatchToVendor(
workOrderId: string | number,
): UseMutationResult<void, Error, DispatchToVendorPayload> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: workOrdersApi.dispatchToVendor,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
invalidateWorkOrders(queryClient);
toast.success("Dispatched successfully");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to dispatch");
},
});
}
export function useUpdateDispatch(
dispatchId: string | number,
workOrderId: string | number,
): UseMutationResult<void, Error, UpdateDispatchPayload> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: workOrdersApi.updateDispatch,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
toast.success("Dispatch updated");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update dispatch");
},
});
}
export function useAddDispatchComment(
dispatchId: string | number,
): UseMutationResult<DispatchComment, Error, AddDispatchCommentInput> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ text, sendEmail }: AddDispatchCommentInput) =>
workOrdersApi.addDispatchComment(dispatchId, text, sendEmail),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
},
onError: (error: Error) => {
toast.error(error.message || "Failed to send message");
},
});
}
export function useUpdateChecklistItem(
dispatchId: string | number,
): UseMutationResult<void, Error, UpdateChecklistItemInput> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ itemId, isCompleted, completedBy }: UpdateChecklistItemInput) =>
workOrdersApi.updateChecklistItem(itemId, isCompleted, completedBy),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update checklist item");
},
});
}
export function useAddChecklistItem(
dispatchId: string | number,
): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (itemText: string) => workOrdersApi.addChecklistItem(dispatchId, itemText),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
},
onError: (error: Error) => {
toast.error(error.message || "Failed to add checklist item");
},
});
}
export function useAddDispatchSignoff(
dispatchId: string | number,
): UseMutationResult<DispatchSignoff, Error, AddDispatchSignoffInput> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: AddDispatchSignoffInput) =>
workOrdersApi.addDispatchSignoff(dispatchId, input),
onSuccess: (_data, variables) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
toast.success(`${variables.signoffType} sign-off collected`);
},
onError: (error: Error) => {
toast.error(error.message || "Failed to save sign-off");
},
});
}
export function useVerifyDispatch(
dispatchId: string | number,
workOrderId: string | number,
): UseMutationResult<void, Error, void> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => workOrdersApi.verifyDispatch(dispatchId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
toast.success("Dispatch verified");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to verify");
},
});
}