mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 06:59:01 +00:00
feat: add follow-ups CRUD module
This commit is contained in:
parent
09a2468ab0
commit
66c8c7cb4a
13 changed files with 798 additions and 0 deletions
239
src/app/(protected)/followups/_components/follow-up-form.tsx
Normal file
239
src/app/(protected)/followups/_components/follow-up-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/followups/edit/[id].tsx
Normal file
1
src/app/(protected)/followups/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "../_components/follow-up-form";
|
||||||
234
src/app/(protected)/followups/index.tsx
Normal file
234
src/app/(protected)/followups/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(protected)/followups/new.tsx
Normal file
1
src/app/(protected)/followups/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "./_components/follow-up-form";
|
||||||
66
src/domain/follow-ups/api/follow-ups-api.ts
Normal file
66
src/domain/follow-ups/api/follow-ups-api.ts
Normal 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) },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
98
src/domain/follow-ups/mappers/follow-up-mapper.ts
Normal file
98
src/domain/follow-ups/mappers/follow-up-mapper.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
15
src/domain/follow-ups/schemas/follow-up-schema.ts
Normal file
15
src/domain/follow-ups/schemas/follow-up-schema.ts
Normal 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>;
|
||||||
44
src/domain/follow-ups/types/follow-up.ts
Normal file
44
src/domain/follow-ups/types/follow-up.ts
Normal 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;
|
||||||
|
}
|
||||||
24
src/domain/follow-ups/use-cases/use-create-follow-up.ts
Normal file
24
src/domain/follow-ups/use-cases/use-create-follow-up.ts
Normal 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");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
19
src/domain/follow-ups/use-cases/use-delete-follow-up.ts
Normal file
19
src/domain/follow-ups/use-cases/use-delete-follow-up.ts
Normal 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");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
13
src/domain/follow-ups/use-cases/use-follow-up-detail.ts
Normal file
13
src/domain/follow-ups/use-cases/use-follow-up-detail.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
14
src/domain/follow-ups/use-cases/use-follow-ups-list.ts
Normal file
14
src/domain/follow-ups/use-cases/use-follow-ups-list.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
30
src/domain/follow-ups/use-cases/use-update-follow-up.ts
Normal file
30
src/domain/follow-ups/use-cases/use-update-follow-up.ts
Normal 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");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue