diff --git a/src/app/(protected)/followups/_components/follow-up-form.tsx b/src/app/(protected)/followups/_components/follow-up-form.tsx new file mode 100644 index 00000000..46f3b992 --- /dev/null +++ b/src/app/(protected)/followups/_components/follow-up-form.tsx @@ -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({ + 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 ( + + + + ); + } + + return ( + + + + {isEdit ? "Edit Follow-up" : "Create Follow-up"} + + {error && ( + + {error instanceof Error ? error.message : "Failed to load follow-up"} + + )} + + + + + + + + + + + ( + + Assigned Employee + + + )} + /> + + ( + + Account + + + )} + /> + + ( + + Location + + + )} + /> + + + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/followups/edit/[id].tsx b/src/app/(protected)/followups/edit/[id].tsx new file mode 100644 index 00000000..f491a56f --- /dev/null +++ b/src/app/(protected)/followups/edit/[id].tsx @@ -0,0 +1 @@ +export { default } from "../_components/follow-up-form"; diff --git a/src/app/(protected)/followups/index.tsx b/src/app/(protected)/followups/index.tsx new file mode 100644 index 00000000..c73d3338 --- /dev/null +++ b/src/app/(protected)/followups/index.tsx @@ -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(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 ( + + + + + Follow-ups + + + {totalCount} total + + {isFetching && !isLoading && } + + + + { + setSearch(e.target.value); + setPage(0); + }} + placeholder="Search follow-ups..." + className="min-w-sidebar" + /> + + Status + + + + + + + {error && ( + + {error instanceof Error ? error.message : "Failed to load follow-ups"} + + )} + + + + + + Reason + Schedule + Assigned To + Account + Location + WO # + Status + + Actions + + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + No follow-ups found + + + ) : ( + rows.map((row) => ( + + + + + + {toDateInputValue(row.scheduleDate) || "—"} + {row.scheduleStartTime ? ` ${row.scheduleStartTime.slice(0, 5)}` : ""} + + {row.assignedTo || "—"} + {row.accountName || "—"} + {row.locationName || "—"} + {row.internalWONumber || row.workOrderNumber || "—"} + + + + + + navigate(`/followups/edit/${row.id}`)} + > + + + + + setDeleteTarget(row)}> + + + + + + )) + )} + +
+
+ + setPage(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => { + setPageSize(Number(e.target.value)); + setPage(0); + }} + rowsPerPageOptions={[12, 24, 48]} + /> + + setDeleteTarget(null)}> + Delete Follow-up + + Are you sure you want to delete this follow-up? + + + + + + +
+ ); +} diff --git a/src/app/(protected)/followups/new.tsx b/src/app/(protected)/followups/new.tsx new file mode 100644 index 00000000..b1223e90 --- /dev/null +++ b/src/app/(protected)/followups/new.tsx @@ -0,0 +1 @@ +export { default } from "./_components/follow-up-form"; diff --git a/src/domain/follow-ups/api/follow-ups-api.ts b/src/domain/follow-ups/api/follow-ups-api.ts new file mode 100644 index 00000000..29f45797 --- /dev/null +++ b/src/domain/follow-ups/api/follow-ups-api.ts @@ -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 { + return { + search: params.search ?? "", + page: params.page ?? 1, + pageSize: params.pageSize ?? 12, + status: params.status ?? "Open", + }; +} + +export const followUpsApi = { + getList: async (params: FollowUpsListParams = {}): Promise => { + const data = await apiGet(API_PATHS.followUp.getList, { + searchParams: buildListParams(params), + }); + const paginated = handlePaginatedResponse(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 => { + const data = await apiGet(API_PATHS.followUp.getById, { + searchParams: { id: String(id) }, + }); + return mapFollowUp(handleApiResponse(data)); + }, + + create: async (payload: unknown): Promise => { + const data = await apiPost(API_PATHS.followUp.create, mapFollowUpToBackend(payload)); + return mapFollowUp(handleApiResponse(data)); + }, + + update: async (id: string | number, payload: unknown): Promise => { + const data = await apiPost( + API_PATHS.followUp.update, + mapFollowUpToBackend(payload, id), + ); + return mapFollowUp(handleApiResponse(data)); + }, + + delete: async (id: string | number): Promise => { + await apiPost(API_PATHS.followUp.delete, undefined, { + searchParams: { id: String(id) }, + }); + }, +}; diff --git a/src/domain/follow-ups/mappers/follow-up-mapper.ts b/src/domain/follow-ups/mappers/follow-up-mapper.ts new file mode 100644 index 00000000..2855b32b --- /dev/null +++ b/src/domain/follow-ups/mappers/follow-up-mapper.ts @@ -0,0 +1,98 @@ +import type { FollowUp, FollowUpListItem } from "@/domain/follow-ups/types/follow-up"; + +function asRecord(raw: unknown): Record { + return typeof raw === "object" && raw !== null ? (raw as Record) : {}; +} + +function readString(record: Record, ...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 | number { + const value = record.id ?? record.Id; + if (typeof value === "string" || typeof value === "number") return value; + return ""; +} + +function readNullableId( + record: Record, + ...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 { + 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, + }; +} diff --git a/src/domain/follow-ups/schemas/follow-up-schema.ts b/src/domain/follow-ups/schemas/follow-up-schema.ts new file mode 100644 index 00000000..5b5f2c20 --- /dev/null +++ b/src/domain/follow-ups/schemas/follow-up-schema.ts @@ -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; diff --git a/src/domain/follow-ups/types/follow-up.ts b/src/domain/follow-ups/types/follow-up.ts new file mode 100644 index 00000000..ce0b5fd6 --- /dev/null +++ b/src/domain/follow-ups/types/follow-up.ts @@ -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; +} diff --git a/src/domain/follow-ups/use-cases/use-create-follow-up.ts b/src/domain/follow-ups/use-cases/use-create-follow-up.ts new file mode 100644 index 00000000..4d778780 --- /dev/null +++ b/src/domain/follow-ups/use-cases/use-create-follow-up.ts @@ -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 { + 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"); + }, + }); +} diff --git a/src/domain/follow-ups/use-cases/use-delete-follow-up.ts b/src/domain/follow-ups/use-cases/use-delete-follow-up.ts new file mode 100644 index 00000000..ea5f98da --- /dev/null +++ b/src/domain/follow-ups/use-cases/use-delete-follow-up.ts @@ -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 { + 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"); + }, + }); +} diff --git a/src/domain/follow-ups/use-cases/use-follow-up-detail.ts b/src/domain/follow-ups/use-cases/use-follow-up-detail.ts new file mode 100644 index 00000000..61d1b401 --- /dev/null +++ b/src/domain/follow-ups/use-cases/use-follow-up-detail.ts @@ -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 { + return useQuery({ + queryKey: queryKeys.followUps.detail(id ?? ""), + queryFn: () => followUpsApi.getById(requireQueryParam(id, "Follow-up id")), + enabled: Boolean(id), + }); +} diff --git a/src/domain/follow-ups/use-cases/use-follow-ups-list.ts b/src/domain/follow-ups/use-cases/use-follow-ups-list.ts new file mode 100644 index 00000000..fd2f1d54 --- /dev/null +++ b/src/domain/follow-ups/use-cases/use-follow-ups-list.ts @@ -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 { + return useQuery({ + queryKey: queryKeys.followUps.list(params as Record), + queryFn: () => followUpsApi.getList(params), + placeholderData: keepPreviousData, + }); +} diff --git a/src/domain/follow-ups/use-cases/use-update-follow-up.ts b/src/domain/follow-ups/use-cases/use-update-follow-up.ts new file mode 100644 index 00000000..15365975 --- /dev/null +++ b/src/domain/follow-ups/use-cases/use-update-follow-up.ts @@ -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 { + 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"); + }, + }); +}