feat: add follow-ups CRUD module

This commit is contained in:
Arthur Bassi 2026-06-12 11:54:30 -03:00
parent 09a2468ab0
commit 66c8c7cb4a
13 changed files with 798 additions and 0 deletions

View file

@ -0,0 +1,239 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router-dom";
import {
Alert,
Box,
Button,
CircularProgress,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
import { useEmployeesList } from "@/domain/employees/use-cases/use-employees-list";
import {
followUpSchema,
type FollowUpFormValues,
} from "@/domain/follow-ups/schemas/follow-up-schema";
import { useCreateFollowUp } from "@/domain/follow-ups/use-cases/use-create-follow-up";
import { useFollowUpDetail } from "@/domain/follow-ups/use-cases/use-follow-up-detail";
import { useUpdateFollowUp } from "@/domain/follow-ups/use-cases/use-update-follow-up";
import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list";
const defaultValues: FollowUpFormValues = {
reason: "",
scheduleDate: "",
scheduleStartTime: "09:00",
employeeId: null,
accountId: null,
locationId: null,
workOrderId: null,
};
export default function FollowUpFormPage() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = Boolean(id);
const { data: followUp, isLoading, error } = useFollowUpDetail(id);
const { data: employeesData } = useEmployeesList({ page: 1, pageSize: 500 });
const { data: accountsData } = useAccountsList({ page: 1, pageSize: 500 });
const { data: locationsData } = useLocationsList({ page: 1, pageSize: 500 });
const createFollowUp = useCreateFollowUp();
const updateFollowUp = useUpdateFollowUp();
const {
register,
handleSubmit,
reset,
control,
formState: { errors },
} = useForm<FollowUpFormValues>({
resolver: zodResolver(followUpSchema),
defaultValues,
});
useEffect(() => {
if (followUp && isEdit) {
reset({
reason: followUp.reason,
scheduleDate: followUp.scheduleDate,
scheduleStartTime: followUp.scheduleStartTime,
employeeId: followUp.employeeId,
accountId: followUp.accountId,
locationId: followUp.locationId,
workOrderId: followUp.workOrderId,
});
}
}, [followUp, isEdit, reset]);
const employees = employeesData?.items ?? [];
const accounts = accountsData?.items ?? [];
const locations = locationsData?.items ?? [];
const isSaving = createFollowUp.isPending || updateFollowUp.isPending;
const onSubmit = (values: FollowUpFormValues) => {
if (isEdit && id) {
updateFollowUp.mutate({ id, values });
return;
}
createFollowUp.mutate(values);
};
if (isEdit && isLoading) {
return (
<Box className="flex justify-center p-10">
<CircularProgress />
</Box>
);
}
return (
<Box className="mx-auto max-w-3xl p-4">
<Paper variant="outlined" className="p-6">
<Stack spacing={4}>
<Typography variant="h5">{isEdit ? "Edit Follow-up" : "Create Follow-up"}</Typography>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load follow-up"}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
<Stack spacing={3}>
<TextField
label="Reason"
required
fullWidth
multiline
minRows={2}
error={Boolean(errors.reason)}
helperText={errors.reason?.message}
{...register("reason")}
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="Schedule Date"
type="date"
required
fullWidth
error={Boolean(errors.scheduleDate)}
helperText={errors.scheduleDate?.message}
slotProps={{ inputLabel: { shrink: true } }}
{...register("scheduleDate")}
/>
<TextField
label="Start Time"
type="time"
required
fullWidth
error={Boolean(errors.scheduleStartTime)}
helperText={errors.scheduleStartTime?.message}
slotProps={{ inputLabel: { shrink: true } }}
{...register("scheduleStartTime")}
/>
</Stack>
<Controller
name="employeeId"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel>Assigned Employee</InputLabel>
<Select
label="Assigned Employee"
value={field.value != null ? String(field.value) : ""}
onChange={(e) =>
field.onChange(e.target.value ? Number(e.target.value) : null)
}
>
<MenuItem value="">None</MenuItem>
{employees.map((emp) => (
<MenuItem key={String(emp.id)} value={String(emp.id)}>
{emp.name}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
name="accountId"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel>Account</InputLabel>
<Select
label="Account"
value={field.value != null ? String(field.value) : ""}
onChange={(e) =>
field.onChange(e.target.value ? Number(e.target.value) : null)
}
>
<MenuItem value="">None</MenuItem>
{accounts.map((acc) => (
<MenuItem key={String(acc.id)} value={String(acc.id)}>
{acc.name}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
name="locationId"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel>Location</InputLabel>
<Select
label="Location"
value={field.value != null ? String(field.value) : ""}
onChange={(e) =>
field.onChange(e.target.value ? Number(e.target.value) : null)
}
>
<MenuItem value="">None</MenuItem>
{locations.map((loc) => (
<MenuItem key={String(loc.id)} value={String(loc.id)}>
{loc.name}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
<TextField label="Work Order ID" fullWidth {...register("workOrderId")} />
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Button
variant="outlined"
onClick={() => navigate("/followups")}
disabled={isSaving}
>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={isSaving}>
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
</Button>
</Stack>
</Stack>
</Box>
</Stack>
</Paper>
</Box>
);
}

View file

@ -0,0 +1 @@
export { default } from "../_components/follow-up-form";

View file

@ -0,0 +1,234 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
FormControl,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import type { FollowUpListItem } from "@/domain/follow-ups/types/follow-up";
import { useDeleteFollowUp } from "@/domain/follow-ups/use-cases/use-delete-follow-up";
import { useFollowUpsList } from "@/domain/follow-ups/use-cases/use-follow-ups-list";
import { useDebounce } from "@/hooks/use-debounce";
import { toDateInputValue } from "@/lib/time-utils";
const STATUS_OPTIONS = ["Open", "Completed", "Cancelled", ""];
export default function FollowUpsListPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [status, setStatus] = useState("Open");
const [deleteTarget, setDeleteTarget] = useState<FollowUpListItem | null>(null);
const debouncedSearch = useDebounce(search, 300);
const deleteFollowUp = useDeleteFollowUp();
const listParams = useMemo(
() => ({ search: debouncedSearch, page: page + 1, pageSize, status }),
[debouncedSearch, page, pageSize, status],
);
const { data, isLoading, isFetching, error } = useFollowUpsList(listParams);
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
return (
<Box className="flex flex-col gap-4 p-4">
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
>
<Stack direction="row" spacing={2} alignItems="baseline">
<Typography variant="h5" component="h1">
Follow-ups
</Typography>
<Typography variant="body2" color="text.secondary">
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
<TextField
size="small"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
placeholder="Search follow-ups..."
className="min-w-sidebar"
/>
<FormControl size="small" className="min-w-[140px]">
<InputLabel>Status</InputLabel>
<Select
label="Status"
value={status}
onChange={(e) => {
setStatus(e.target.value);
setPage(0);
}}
>
{STATUS_OPTIONS.map((s) => (
<MenuItem key={s || "all"} value={s}>
{s || "All"}
</MenuItem>
))}
</Select>
</FormControl>
<Button variant="contained" onClick={() => navigate("/followups/new")}>
+ New Follow-up
</Button>
</Stack>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load follow-ups"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Reason</TableCell>
<TableCell className="font-semibold">Schedule</TableCell>
<TableCell className="font-semibold">Assigned To</TableCell>
<TableCell className="font-semibold">Account</TableCell>
<TableCell className="font-semibold">Location</TableCell>
<TableCell className="font-semibold">WO #</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<Typography color="text.secondary">No follow-ups found</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/followups/edit/${row.id}`)}
>
{row.reason || "—"}
</Button>
</TableCell>
<TableCell>
{toDateInputValue(row.scheduleDate) || "—"}
{row.scheduleStartTime ? ` ${row.scheduleStartTime.slice(0, 5)}` : ""}
</TableCell>
<TableCell>{row.assignedTo || "—"}</TableCell>
<TableCell>{row.accountName || "—"}</TableCell>
<TableCell>{row.locationName || "—"}</TableCell>
<TableCell>{row.internalWONumber || row.workOrderNumber || "—"}</TableCell>
<TableCell>
<Chip size="small" label={row.status || "—"} />
</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton
size="small"
onClick={() => navigate(`/followups/edit/${row.id}`)}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton size="small" onClick={() => setDeleteTarget(row)}>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_e, nextPage) => setPage(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => {
setPageSize(Number(e.target.value));
setPage(0);
}}
rowsPerPageOptions={[12, 24, 48]}
/>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Follow-up</DialogTitle>
<DialogContent>
<DialogContentText>Are you sure you want to delete this follow-up?</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)} disabled={deleteFollowUp.isPending}>
Cancel
</Button>
<Button
color="error"
variant="contained"
disabled={deleteFollowUp.isPending}
onClick={() => {
if (!deleteTarget) return;
deleteFollowUp.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null),
});
}}
>
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

@ -0,0 +1 @@
export { default } from "./_components/follow-up-form";

View file

@ -0,0 +1,66 @@
import { API_PATHS } from "@/api/api-paths";
import { apiGet, apiPost } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import {
mapFollowUp,
mapFollowUpListItems,
mapFollowUpToBackend,
} from "@/domain/follow-ups/mappers/follow-up-mapper";
import type {
FollowUp,
FollowUpsListParams,
FollowUpsListResult,
} from "@/domain/follow-ups/types/follow-up";
function buildListParams(params: FollowUpsListParams): Record<string, string | number> {
return {
search: params.search ?? "",
page: params.page ?? 1,
pageSize: params.pageSize ?? 12,
status: params.status ?? "Open",
};
}
export const followUpsApi = {
getList: async (params: FollowUpsListParams = {}): Promise<FollowUpsListResult> => {
const data = await apiGet<unknown>(API_PATHS.followUp.getList, {
searchParams: buildListParams(params),
});
const paginated = handlePaginatedResponse<unknown>(data, {
page: params.page,
pageSize: params.pageSize,
});
return {
items: mapFollowUpListItems(paginated.items),
totalCount: paginated.totalCount,
page: paginated.page,
pageSize: paginated.pageSize,
};
},
getById: async (id: string | number): Promise<FollowUp> => {
const data = await apiGet<unknown>(API_PATHS.followUp.getById, {
searchParams: { id: String(id) },
});
return mapFollowUp(handleApiResponse(data));
},
create: async (payload: unknown): Promise<FollowUp> => {
const data = await apiPost<unknown>(API_PATHS.followUp.create, mapFollowUpToBackend(payload));
return mapFollowUp(handleApiResponse(data));
},
update: async (id: string | number, payload: unknown): Promise<FollowUp> => {
const data = await apiPost<unknown>(
API_PATHS.followUp.update,
mapFollowUpToBackend(payload, id),
);
return mapFollowUp(handleApiResponse(data));
},
delete: async (id: string | number): Promise<void> => {
await apiPost<unknown>(API_PATHS.followUp.delete, undefined, {
searchParams: { id: String(id) },
});
},
};

View file

@ -0,0 +1,98 @@
import type { FollowUp, FollowUpListItem } from "@/domain/follow-ups/types/follow-up";
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") return String(value);
}
return "";
}
function readId(record: Record<string, unknown>): string | number {
const value = record.id ?? record.Id;
if (typeof value === "string" || typeof value === "number") return value;
return "";
}
function readNullableId(
record: Record<string, unknown>,
...keys: string[]
): string | number | null {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" || typeof value === "number") return value;
}
return null;
}
function normalizeTime(value: string): string {
if (!value) return "09:00";
const match = value.match(/^(\d{2}:\d{2})/);
return match ? match[1] : "09:00";
}
export function mapFollowUpListItem(raw: unknown): FollowUpListItem {
const item = asRecord(raw);
return {
id: readId(item),
reason: readString(item, "reason", "Reason"),
scheduleDate: readString(item, "scheduleDate", "ScheduleDate"),
scheduleStartTime: readString(item, "scheduleStartTime", "ScheduleStartTime"),
employeeId: readNullableId(item, "employeeId", "EmployeeId"),
workOrderId: readNullableId(item, "workOrderId", "WorkOrderId"),
accountId: readNullableId(item, "accountId", "AccountId"),
locationId: readNullableId(item, "locationId", "LocationId"),
accountName: readString(item, "accountName", "AccountName"),
assignedTo: readString(item, "assignedTo", "AssignedTo"),
locationName: readString(item, "locationName", "LocationName"),
workOrderNumber: readString(item, "workOrderNumber", "WorkOrderNumber"),
internalWONumber: readString(item, "internalWONumber", "InternalWONumber"),
status: readString(item, "status", "Status"),
createdDate: readString(item, "createdDate", "CreatedDate"),
createdByName: readString(item, "createdByName", "CreatedByName"),
};
}
export function mapFollowUpListItems(rawItems: unknown[]): FollowUpListItem[] {
return rawItems.map(mapFollowUpListItem);
}
export function mapFollowUp(raw: unknown): FollowUp {
const item = asRecord(raw);
return {
id: readId(item) || undefined,
reason: readString(item, "reason", "Reason"),
scheduleDate: readString(item, "scheduleDate", "ScheduleDate").slice(0, 10),
scheduleStartTime: normalizeTime(readString(item, "scheduleStartTime", "ScheduleStartTime")),
employeeId: readNullableId(item, "employeeId", "EmployeeId"),
accountId: readNullableId(item, "accountId", "AccountId"),
locationId: readNullableId(item, "locationId", "LocationId"),
workOrderId: readNullableId(item, "workOrderId", "WorkOrderId"),
status: readString(item, "status", "Status") || undefined,
};
}
/** Unified MVC payload — Create and Update share the same shape. */
export function mapFollowUpToBackend(
followUp: unknown,
id?: string | number,
): Record<string, unknown> {
const item = asRecord(followUp);
const time = normalizeTime(readString(item, "scheduleStartTime", "ScheduleStartTime"));
return {
Id: id ? Number(id) : 0,
Reason: item.reason ?? "",
ScheduleDate: item.scheduleDate ?? "",
ScheduleStartTime: `${time}:00`,
EmployeeId: item.employeeId ? Number(item.employeeId) : null,
AccountId: item.accountId ? Number(item.accountId) : null,
LocationId: item.locationId ? Number(item.locationId) : null,
WorkOrderId: item.workOrderId ? Number(item.workOrderId) : null,
};
}

View file

@ -0,0 +1,15 @@
import { z } from "zod";
const optionalId = z.union([z.string(), z.number()]).nullable().optional();
export const followUpSchema = z.object({
reason: z.string().min(1, "Reason is required"),
scheduleDate: z.string().min(1, "Schedule date is required"),
scheduleStartTime: z.string().min(1, "Start time is required"),
employeeId: optionalId,
accountId: optionalId,
locationId: optionalId,
workOrderId: optionalId,
});
export type FollowUpFormValues = z.infer<typeof followUpSchema>;

View file

@ -0,0 +1,44 @@
export interface FollowUpListItem {
id: string | number;
reason: string;
scheduleDate: string;
scheduleStartTime: string;
employeeId: string | number | null;
workOrderId: string | number | null;
accountId: string | number | null;
locationId: string | number | null;
accountName: string;
assignedTo: string;
locationName: string;
workOrderNumber: string;
internalWONumber: string;
status: string;
createdDate: string;
createdByName: string;
}
export interface FollowUp {
id?: string | number;
reason: string;
scheduleDate: string;
scheduleStartTime: string;
employeeId: string | number | null;
accountId: string | number | null;
locationId: string | number | null;
workOrderId: string | number | null;
status?: string;
}
export interface FollowUpsListParams {
search?: string;
page?: number;
pageSize?: number;
status?: string;
}
export interface FollowUpsListResult {
items: FollowUpListItem[];
totalCount: number;
page: number;
pageSize: number;
}

View file

@ -0,0 +1,24 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { followUpsApi } from "@/domain/follow-ups/api/follow-ups-api";
import type { FollowUpFormValues } from "@/domain/follow-ups/schemas/follow-up-schema";
import type { FollowUp } from "@/domain/follow-ups/types/follow-up";
import { queryKeys } from "@/infra/query-key/query-key";
export function useCreateFollowUp(): UseMutationResult<FollowUp, Error, FollowUpFormValues> {
const navigate = useNavigate();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: FollowUpFormValues) => followUpsApi.create(values),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.followUps.all });
toast.success("Follow-up created successfully!");
navigate("/followups");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create follow-up");
},
});
}

View file

@ -0,0 +1,19 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { followUpsApi } from "@/domain/follow-ups/api/follow-ups-api";
import { queryKeys } from "@/infra/query-key/query-key";
export function useDeleteFollowUp(): UseMutationResult<void, Error, string | number> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string | number) => followUpsApi.delete(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.followUps.all });
toast.success("Follow-up deleted");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to delete follow-up");
},
});
}

View file

@ -0,0 +1,13 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { followUpsApi } from "@/domain/follow-ups/api/follow-ups-api";
import type { FollowUp } from "@/domain/follow-ups/types/follow-up";
import { queryKeys } from "@/infra/query-key/query-key";
import { requireQueryParam } from "@/lib/query/require-query-param";
export function useFollowUpDetail(id: string | undefined): UseQueryResult<FollowUp, Error> {
return useQuery({
queryKey: queryKeys.followUps.detail(id ?? ""),
queryFn: () => followUpsApi.getById(requireQueryParam(id, "Follow-up id")),
enabled: Boolean(id),
});
}

View file

@ -0,0 +1,14 @@
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
import { followUpsApi } from "@/domain/follow-ups/api/follow-ups-api";
import type { FollowUpsListParams, FollowUpsListResult } from "@/domain/follow-ups/types/follow-up";
import { queryKeys } from "@/infra/query-key/query-key";
export function useFollowUpsList(
params: FollowUpsListParams,
): UseQueryResult<FollowUpsListResult, Error> {
return useQuery({
queryKey: queryKeys.followUps.list(params as Record<string, unknown>),
queryFn: () => followUpsApi.getList(params),
placeholderData: keepPreviousData,
});
}

View file

@ -0,0 +1,30 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { followUpsApi } from "@/domain/follow-ups/api/follow-ups-api";
import type { FollowUpFormValues } from "@/domain/follow-ups/schemas/follow-up-schema";
import type { FollowUp } from "@/domain/follow-ups/types/follow-up";
import { queryKeys } from "@/infra/query-key/query-key";
interface UpdateFollowUpInput {
id: string | number;
values: FollowUpFormValues;
}
export function useUpdateFollowUp(): UseMutationResult<FollowUp, Error, UpdateFollowUpInput> {
const navigate = useNavigate();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, values }: UpdateFollowUpInput) => followUpsApi.update(id, values),
onSuccess: (_data, variables) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.followUps.all });
void queryClient.invalidateQueries({ queryKey: queryKeys.followUps.detail(variables.id) });
toast.success("Follow-up updated successfully!");
navigate("/followups");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update follow-up");
},
});
}