mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 12:22:03 +00:00
feat: add employees CRUD module
This commit is contained in:
parent
ef5c73fca3
commit
a60da31f4c
13 changed files with 1018 additions and 0 deletions
363
src/app/(protected)/employees/_components/employee-form.tsx
Normal file
363
src/app/(protected)/employees/_components/employee-form.tsx
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
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,
|
||||
Divider,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
employeeDefaultValues,
|
||||
employeeSchema,
|
||||
type EmployeeFormValues,
|
||||
} from "@/domain/employees/schemas/employee-schema";
|
||||
import { useCreateEmployee } from "@/domain/employees/use-cases/use-create-employee";
|
||||
import { useEmployeeDetail } from "@/domain/employees/use-cases/use-employee-detail";
|
||||
import { useUpdateEmployee } from "@/domain/employees/use-cases/use-update-employee";
|
||||
|
||||
const DEPARTMENTS = ["Administration", "Sales", "Service", "Field Service"] as const;
|
||||
const TIME_ZONES = [
|
||||
"Eastern Standard Time",
|
||||
"Central Standard Time",
|
||||
"Mountain Standard Time",
|
||||
"Pacific Standard Time",
|
||||
] as const;
|
||||
const JOB_TITLES = [
|
||||
"Technician",
|
||||
"Service Associate",
|
||||
"Accountant",
|
||||
"Salesperson",
|
||||
"President",
|
||||
] as const;
|
||||
const ROLES = [
|
||||
"Admin",
|
||||
"Administrator User",
|
||||
"Dispatcher",
|
||||
"Editor User",
|
||||
"Employee",
|
||||
"Read Only User",
|
||||
"Technician",
|
||||
] as const;
|
||||
const US_STATES = ["AL", "AK", "AZ", "CA", "FL", "NY", "TX"] as const;
|
||||
|
||||
function SelectField({
|
||||
name,
|
||||
label,
|
||||
control,
|
||||
options,
|
||||
required,
|
||||
error,
|
||||
helperText,
|
||||
}: {
|
||||
name: keyof EmployeeFormValues;
|
||||
label: string;
|
||||
control: ReturnType<typeof useForm<EmployeeFormValues>>["control"];
|
||||
options: readonly string[];
|
||||
required?: boolean;
|
||||
error?: boolean;
|
||||
helperText?: string;
|
||||
}) {
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth required={required} error={error}>
|
||||
<InputLabel id={`${name}-label`}>{label}</InputLabel>
|
||||
<Select {...field} labelId={`${name}-label`} label={label} value={field.value ?? ""}>
|
||||
<MenuItem value="">
|
||||
<em>Select...</em>
|
||||
</MenuItem>
|
||||
{options.map((option) => (
|
||||
<MenuItem key={option} value={option}>
|
||||
{option}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{helperText && (
|
||||
<Typography variant="caption" color="error" className="mt-1">
|
||||
{helperText}
|
||||
</Typography>
|
||||
)}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EmployeeFormPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const isEdit = Boolean(id);
|
||||
|
||||
const { data: employee, isLoading, error } = useEmployeeDetail(id);
|
||||
const createEmployee = useCreateEmployee();
|
||||
const updateEmployee = useUpdateEmployee();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<EmployeeFormValues>({
|
||||
resolver: zodResolver(employeeSchema),
|
||||
defaultValues: employeeDefaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (employee && isEdit) {
|
||||
reset({
|
||||
firstName: employee.firstName,
|
||||
middleName: employee.middleName,
|
||||
lastName: employee.lastName,
|
||||
email: employee.email,
|
||||
phone: employee.phone,
|
||||
jobTitle: employee.jobTitle,
|
||||
role: employee.role,
|
||||
status: employee.status,
|
||||
hireDate: employee.hireDate,
|
||||
dateOfBirth: employee.dateOfBirth,
|
||||
address: employee.address,
|
||||
department: employee.department,
|
||||
timeZone: employee.timeZone,
|
||||
gender: employee.gender,
|
||||
employeeNumber: employee.employeeNumber,
|
||||
regionName: employee.regionName,
|
||||
userName: employee.userName,
|
||||
confirmUserName: employee.confirmUserName,
|
||||
street: employee.street,
|
||||
streetLineTwo: employee.streetLineTwo,
|
||||
city: employee.city,
|
||||
zip: employee.zip,
|
||||
country: employee.country,
|
||||
state: employee.state,
|
||||
county: employee.county,
|
||||
smsAddress: employee.smsAddress,
|
||||
website: employee.website,
|
||||
facebookUrl: employee.facebookUrl,
|
||||
linkedInUrl: employee.linkedInUrl,
|
||||
twitterUrl: employee.twitterUrl,
|
||||
});
|
||||
}
|
||||
}, [employee, isEdit, reset]);
|
||||
|
||||
const isSaving = createEmployee.isPending || updateEmployee.isPending;
|
||||
|
||||
const onSubmit = (values: EmployeeFormValues) => {
|
||||
if (isEdit && id) {
|
||||
updateEmployee.mutate({ id, values });
|
||||
return;
|
||||
}
|
||||
createEmployee.mutate(values);
|
||||
};
|
||||
|
||||
if (isEdit && isLoading) {
|
||||
return (
|
||||
<Box className="flex justify-center p-10">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="mx-auto max-w-5xl p-4">
|
||||
<Paper variant="outlined" className="p-6">
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="h5" component="h1">
|
||||
{isEdit ? "Edit an Employee" : "Create an Employee"}
|
||||
</Typography>
|
||||
<Button variant="text" onClick={() => navigate("/employees")}>
|
||||
Back to list
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load employee"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
<Typography variant="subtitle1" className="font-semibold">
|
||||
General Details
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("firstName")}
|
||||
label="First Name"
|
||||
required
|
||||
error={Boolean(errors.firstName)}
|
||||
helperText={errors.firstName?.message}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField {...register("middleName")} label="Middle Name" fullWidth />
|
||||
<TextField
|
||||
{...register("lastName")}
|
||||
label="Last Name"
|
||||
required
|
||||
error={Boolean(errors.lastName)}
|
||||
helperText={errors.lastName?.message}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<SelectField
|
||||
name="department"
|
||||
label="Department"
|
||||
control={control}
|
||||
options={DEPARTMENTS}
|
||||
/>
|
||||
<SelectField
|
||||
name="timeZone"
|
||||
label="Time Zone"
|
||||
control={control}
|
||||
options={TIME_ZONES}
|
||||
required
|
||||
error={Boolean(errors.timeZone)}
|
||||
helperText={errors.timeZone?.message}
|
||||
/>
|
||||
<SelectField
|
||||
name="gender"
|
||||
label="Gender"
|
||||
control={control}
|
||||
options={["Male", "Female", "Not specified", "Other"]}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("dateOfBirth")}
|
||||
label="Date of Birth"
|
||||
type="date"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
fullWidth
|
||||
/>
|
||||
<SelectField
|
||||
name="jobTitle"
|
||||
label="Job Title"
|
||||
control={control}
|
||||
options={JOB_TITLES}
|
||||
/>
|
||||
<TextField {...register("employeeNumber")} label="Employee #" fullWidth />
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("hireDate")}
|
||||
label="Hire Date"
|
||||
type="date"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
fullWidth
|
||||
/>
|
||||
<SelectField
|
||||
name="regionName"
|
||||
label="Region Name"
|
||||
control={control}
|
||||
options={["Region 1", "Region 2"]}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Typography variant="subtitle1" className="font-semibold">
|
||||
Login & Role
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{isEdit
|
||||
? "If the Username is changed, the Employee's account will have to be confirmed again before they can log in."
|
||||
: "A confirmation email will be sent to activate the Employee's account."}
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<SelectField
|
||||
name="role"
|
||||
label="Role"
|
||||
control={control}
|
||||
options={ROLES}
|
||||
required
|
||||
error={Boolean(errors.role)}
|
||||
helperText={errors.role?.message}
|
||||
/>
|
||||
<TextField
|
||||
{...register("userName")}
|
||||
label="User Name"
|
||||
type="email"
|
||||
required
|
||||
error={Boolean(errors.userName)}
|
||||
helperText={errors.userName?.message}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
{...register("confirmUserName")}
|
||||
label="Confirm User Name"
|
||||
type="email"
|
||||
required
|
||||
error={Boolean(errors.confirmUserName)}
|
||||
helperText={errors.confirmUserName?.message}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Typography variant="subtitle1" className="font-semibold">
|
||||
Address
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField {...register("street")} label="Address 1" fullWidth />
|
||||
<TextField {...register("streetLineTwo")} label="Address 2" fullWidth />
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField {...register("city")} label="City" fullWidth />
|
||||
<TextField {...register("zip")} label="Zip/Postal Code" fullWidth />
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<SelectField name="country" label="Country" control={control} options={["US"]} />
|
||||
<SelectField
|
||||
name="state"
|
||||
label="State / Province"
|
||||
control={control}
|
||||
options={US_STATES}
|
||||
/>
|
||||
<TextField {...register("county")} label="County" fullWidth />
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Typography variant="subtitle1" className="font-semibold">
|
||||
Additional Details
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField {...register("smsAddress")} label="SMS Address" type="email" fullWidth />
|
||||
<TextField {...register("website")} label="Website" fullWidth />
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField {...register("facebookUrl")} label="Facebook" fullWidth />
|
||||
<TextField {...register("linkedInUrl")} label="LinkedIn" fullWidth />
|
||||
<TextField {...register("twitterUrl")} label="Twitter" fullWidth />
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" justifyContent="flex-end" spacing={2}>
|
||||
<Button variant="outlined" onClick={() => navigate("/employees")} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="contained" disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/employees/edit/[id].tsx
Normal file
1
src/app/(protected)/employees/edit/[id].tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../_components/employee-form";
|
||||
232
src/app/(protected)/employees/index.tsx
Normal file
232
src/app/(protected)/employees/index.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
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,
|
||||
IconButton,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TablePagination,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type { EmployeeListItem } from "@/domain/employees/types/employee";
|
||||
import { useDeleteEmployee } from "@/domain/employees/use-cases/use-delete-employee";
|
||||
import { useEmployeesList } from "@/domain/employees/use-cases/use-employees-list";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
|
||||
const STATUS_COLORS: Record<string, "default" | "success" | "warning" | "error"> = {
|
||||
Active: "success",
|
||||
Inactive: "default",
|
||||
};
|
||||
|
||||
export default function EmployeesListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(12);
|
||||
const [deleteTarget, setDeleteTarget] = useState<EmployeeListItem | null>(null);
|
||||
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const deleteEmployee = useDeleteEmployee();
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
search: debouncedSearch,
|
||||
page: page + 1,
|
||||
pageSize,
|
||||
}),
|
||||
[debouncedSearch, page, pageSize],
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching, error } = useEmployeesList(listParams);
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const totalCount = data?.totalCount ?? 0;
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (!deleteTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteEmployee.mutate(deleteTarget.id, {
|
||||
onSuccess: () => setDeleteTarget(null),
|
||||
});
|
||||
};
|
||||
|
||||
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">
|
||||
Employees
|
||||
</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={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
placeholder="Search employees..."
|
||||
className="min-w-sidebar"
|
||||
/>
|
||||
<Button variant="contained" onClick={() => navigate("/employees/new")}>
|
||||
+ New Employee
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load employees"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className="font-semibold">Name</TableCell>
|
||||
<TableCell className="font-semibold">Job Title</TableCell>
|
||||
<TableCell className="font-semibold">Email</TableCell>
|
||||
<TableCell className="font-semibold">Phone</TableCell>
|
||||
<TableCell className="font-semibold">Department</TableCell>
|
||||
<TableCell className="font-semibold">Status</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
Actions
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center" className="py-10">
|
||||
<CircularProgress size={28} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center" className="py-10">
|
||||
<Typography color="text.secondary">No employees found</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={String(row.id)} hover>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
className="font-semibold"
|
||||
onClick={() => navigate(`/employees/edit/${row.id}`)}
|
||||
>
|
||||
{row.name || "—"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>{row.jobTitle || "—"}</TableCell>
|
||||
<TableCell>{row.email || "—"}</TableCell>
|
||||
<TableCell>{row.phone || "—"}</TableCell>
|
||||
<TableCell>{row.department || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
size="small"
|
||||
label={row.status || "—"}
|
||||
color={STATUS_COLORS[row.status] ?? "default"}
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Edit employee"
|
||||
onClick={() => navigate(`/employees/edit/${row.id}`)}
|
||||
>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Delete employee"
|
||||
onClick={() => setDeleteTarget(row)}
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={(_event, nextPage) => setPage(nextPage)}
|
||||
rowsPerPage={pageSize}
|
||||
onRowsPerPageChange={(event) => {
|
||||
setPageSize(Number(event.target.value));
|
||||
setPage(0);
|
||||
}}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>Delete Employee</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be
|
||||
undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteTarget(null)} disabled={deleteEmployee.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteEmployee.isPending}
|
||||
>
|
||||
{deleteEmployee.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
src/app/(protected)/employees/new.tsx
Normal file
1
src/app/(protected)/employees/new.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./_components/employee-form";
|
||||
63
src/domain/employees/api/employees-api.ts
Normal file
63
src/domain/employees/api/employees-api.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
|
||||
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
||||
import {
|
||||
mapEmployeeListItems,
|
||||
transformEmployee,
|
||||
transformToBackend,
|
||||
} from "@/domain/employees/mappers/employee-mapper";
|
||||
import type {
|
||||
Employee,
|
||||
EmployeesListParams,
|
||||
EmployeesListResult,
|
||||
} from "@/domain/employees/types/employee";
|
||||
|
||||
function buildSearchParams(params: EmployeesListParams): Record<string, string | number> {
|
||||
return {
|
||||
search: params.search ?? "",
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 12,
|
||||
};
|
||||
}
|
||||
|
||||
export const employeesApi = {
|
||||
getList: async (params: EmployeesListParams = {}): Promise<EmployeesListResult> => {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const data = await apiGet<unknown>(API_PATHS.employee.getList, { searchParams });
|
||||
const paginated = handlePaginatedResponse<unknown>(data, {
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
});
|
||||
|
||||
return {
|
||||
items: mapEmployeeListItems(paginated.items),
|
||||
totalCount: paginated.totalCount,
|
||||
page: paginated.page,
|
||||
pageSize: paginated.pageSize,
|
||||
};
|
||||
},
|
||||
|
||||
getById: async (id: string | number): Promise<Employee | null> => {
|
||||
const data = await apiGet<unknown>(`${API_PATHS.rest.employees}/${id}`);
|
||||
return transformEmployee(handleApiResponse(data));
|
||||
},
|
||||
|
||||
create: async (payload: Employee): Promise<Employee | null> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.rest.employees, transformToBackend(payload));
|
||||
return transformEmployee(handleApiResponse(data));
|
||||
},
|
||||
|
||||
update: async (id: string | number, payload: Employee): Promise<Employee | null> => {
|
||||
const data = await apiPut<unknown>(
|
||||
`${API_PATHS.rest.employees}/${id}`,
|
||||
transformToBackend({ ...payload, id }),
|
||||
);
|
||||
return transformEmployee(handleApiResponse(data));
|
||||
},
|
||||
|
||||
delete: async (id: string | number): Promise<void> => {
|
||||
await apiDelete<unknown>(`${API_PATHS.rest.employees}/${id}`);
|
||||
},
|
||||
|
||||
getDropdown: () => apiGet<unknown>(API_PATHS.employee.dropdown),
|
||||
};
|
||||
123
src/domain/employees/mappers/employee-mapper.ts
Normal file
123
src/domain/employees/mappers/employee-mapper.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import type { Employee, EmployeeListItem } from "@/domain/employees/types/employee";
|
||||
|
||||
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 "";
|
||||
}
|
||||
|
||||
export function mapEmployeeListItem(raw: unknown): EmployeeListItem {
|
||||
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||
const firstName = readString(item, "firstName", "FirstName");
|
||||
const lastName = readString(item, "lastName", "LastName");
|
||||
const name = readString(item, "name", "Name") || `${firstName} ${lastName}`.trim();
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
name,
|
||||
jobTitle: readString(item, "jobTitle", "JobTitle"),
|
||||
email: readString(item, "email", "Email"),
|
||||
phone: readString(item, "phone", "Phone"),
|
||||
department: readString(item, "department", "Department"),
|
||||
status: readString(item, "status", "Status"),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapEmployeeListItems(rawItems: unknown[]): EmployeeListItem[] {
|
||||
return rawItems.map(mapEmployeeListItem);
|
||||
}
|
||||
|
||||
export function transformEmployee(employee: unknown): Employee | null {
|
||||
if (!employee || typeof employee !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = employee as Record<string, unknown>;
|
||||
const userName = readString(item, "userName", "UserName", "email", "Email");
|
||||
|
||||
return {
|
||||
id: readId(item) || undefined,
|
||||
firstName: readString(item, "firstName", "FirstName"),
|
||||
middleName: readString(item, "middleName", "MiddleName"),
|
||||
lastName: readString(item, "lastName", "LastName"),
|
||||
email: readString(item, "email", "Email"),
|
||||
phone: readString(item, "phone", "Phone"),
|
||||
jobTitle: readString(item, "jobTitle", "JobTitle"),
|
||||
role: readString(item, "role", "Role") || "Technician",
|
||||
status: readString(item, "status", "Status") || "Active",
|
||||
hireDate: readString(item, "hireDate", "HireDate"),
|
||||
dateOfBirth: readString(item, "dateOfBirth", "DateOfBirth"),
|
||||
address: readString(item, "address", "Address"),
|
||||
department: readString(item, "department", "Department") || "Field Service",
|
||||
street: readString(item, "street", "Street"),
|
||||
streetLineTwo: readString(item, "streetLineTwo", "StreetLineTwo"),
|
||||
city: readString(item, "city", "City"),
|
||||
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
|
||||
country: readString(item, "country", "Country") || "US",
|
||||
state: readString(item, "state", "State"),
|
||||
county: readString(item, "county", "County"),
|
||||
timeZone: readString(item, "timeZone", "TimeZone") || "Eastern Standard Time",
|
||||
gender: readString(item, "gender", "Gender"),
|
||||
employeeNumber: readString(item, "employeeNumber", "EmployeeNumber"),
|
||||
regionName: readString(item, "regionName", "RegionName"),
|
||||
userName,
|
||||
confirmUserName: userName,
|
||||
smsAddress: readString(item, "smsAddress", "SmsAddress"),
|
||||
website: readString(item, "website", "Website"),
|
||||
facebookUrl: readString(item, "facebookUrl", "FacebookUrl"),
|
||||
linkedInUrl: readString(item, "linkedInUrl", "LinkedInUrl"),
|
||||
twitterUrl: readString(item, "twitterUrl", "TwitterUrl"),
|
||||
};
|
||||
}
|
||||
|
||||
export function transformToBackend(employee: Employee): Record<string, unknown> {
|
||||
const zipCode = employee.zip || "";
|
||||
|
||||
return {
|
||||
id: employee.id,
|
||||
firstName: employee.firstName || "",
|
||||
middleName: employee.middleName || null,
|
||||
lastName: employee.lastName || "",
|
||||
email: employee.email || "",
|
||||
phone: employee.phone || "",
|
||||
jobTitle: employee.jobTitle || "",
|
||||
role: employee.role || "Technician",
|
||||
status: employee.status || "Active",
|
||||
hireDate: employee.hireDate || null,
|
||||
dateOfBirth: employee.dateOfBirth || null,
|
||||
address: employee.address || "",
|
||||
street: employee.street || "",
|
||||
streetLineTwo: employee.streetLineTwo || null,
|
||||
city: employee.city || "",
|
||||
state: employee.state || "",
|
||||
zipCode,
|
||||
country: employee.country || "US",
|
||||
county: employee.county || null,
|
||||
department: employee.department || "Field Service",
|
||||
timeZone: employee.timeZone || "Eastern Standard Time",
|
||||
gender: employee.gender || null,
|
||||
employeeNumber: employee.employeeNumber || null,
|
||||
regionName: employee.regionName || null,
|
||||
userName: employee.userName || employee.email || "",
|
||||
smsAddress: employee.smsAddress || null,
|
||||
website: employee.website || null,
|
||||
facebookUrl: employee.facebookUrl || null,
|
||||
linkedInUrl: employee.linkedInUrl || null,
|
||||
twitterUrl: employee.twitterUrl || null,
|
||||
};
|
||||
}
|
||||
74
src/domain/employees/schemas/employee-schema.ts
Normal file
74
src/domain/employees/schemas/employee-schema.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const employeeSchema = z
|
||||
.object({
|
||||
firstName: z.string().min(1, "First name is required"),
|
||||
middleName: z.string().optional(),
|
||||
lastName: z.string().min(1, "Last name is required"),
|
||||
email: z.string().optional(),
|
||||
phone: z.string().optional(),
|
||||
jobTitle: z.string().optional(),
|
||||
role: z.string().min(1, "Role is required"),
|
||||
status: z.string().optional(),
|
||||
hireDate: z.string().optional(),
|
||||
dateOfBirth: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
department: z.string().optional(),
|
||||
timeZone: z.string().min(1, "Time zone is required"),
|
||||
gender: z.string().optional(),
|
||||
employeeNumber: z.string().optional(),
|
||||
regionName: z.string().optional(),
|
||||
userName: z.string().min(1, "User name is required"),
|
||||
confirmUserName: z.string().min(1, "Confirm user name is required"),
|
||||
street: z.string().optional(),
|
||||
streetLineTwo: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
zip: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
county: z.string().optional(),
|
||||
smsAddress: z.string().optional(),
|
||||
website: z.string().optional(),
|
||||
facebookUrl: z.string().optional(),
|
||||
linkedInUrl: z.string().optional(),
|
||||
twitterUrl: z.string().optional(),
|
||||
})
|
||||
.refine((data) => data.userName === data.confirmUserName, {
|
||||
message: "User names must match",
|
||||
path: ["confirmUserName"],
|
||||
});
|
||||
|
||||
export type EmployeeFormValues = z.infer<typeof employeeSchema>;
|
||||
|
||||
export const employeeDefaultValues: EmployeeFormValues = {
|
||||
firstName: "",
|
||||
middleName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
jobTitle: "",
|
||||
role: "Technician",
|
||||
status: "Active",
|
||||
hireDate: "",
|
||||
dateOfBirth: "",
|
||||
address: "",
|
||||
department: "Field Service",
|
||||
timeZone: "Eastern Standard Time",
|
||||
gender: "",
|
||||
employeeNumber: "",
|
||||
regionName: "",
|
||||
userName: "",
|
||||
confirmUserName: "",
|
||||
street: "",
|
||||
streetLineTwo: "",
|
||||
city: "",
|
||||
zip: "",
|
||||
country: "US",
|
||||
state: "",
|
||||
county: "",
|
||||
smsAddress: "",
|
||||
website: "",
|
||||
facebookUrl: "",
|
||||
linkedInUrl: "",
|
||||
twitterUrl: "",
|
||||
};
|
||||
56
src/domain/employees/types/employee.ts
Normal file
56
src/domain/employees/types/employee.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
export interface EmployeeListItem {
|
||||
id: string | number;
|
||||
name: string;
|
||||
jobTitle: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
department: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface Employee {
|
||||
id?: string | number;
|
||||
firstName: string;
|
||||
middleName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
jobTitle: string;
|
||||
role: string;
|
||||
status: string;
|
||||
hireDate: string;
|
||||
dateOfBirth: string;
|
||||
address: string;
|
||||
department: string;
|
||||
street: string;
|
||||
streetLineTwo: string;
|
||||
city: string;
|
||||
zip: string;
|
||||
country: string;
|
||||
state: string;
|
||||
county: string;
|
||||
timeZone: string;
|
||||
gender: string;
|
||||
employeeNumber: string;
|
||||
regionName: string;
|
||||
userName: string;
|
||||
confirmUserName: string;
|
||||
smsAddress: string;
|
||||
website: string;
|
||||
facebookUrl: string;
|
||||
linkedInUrl: string;
|
||||
twitterUrl: string;
|
||||
}
|
||||
|
||||
export interface EmployeesListParams {
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface EmployeesListResult {
|
||||
items: EmployeeListItem[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
24
src/domain/employees/use-cases/use-create-employee.ts
Normal file
24
src/domain/employees/use-cases/use-create-employee.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 { employeesApi } from "@/domain/employees/api/employees-api";
|
||||
import type { EmployeeFormValues } from "@/domain/employees/schemas/employee-schema";
|
||||
import type { Employee } from "@/domain/employees/types/employee";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export function useCreateEmployee(): UseMutationResult<Employee | null, Error, EmployeeFormValues> {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (values: EmployeeFormValues) => employeesApi.create(values as Employee),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.employees.all });
|
||||
toast.success("Employee created successfully!");
|
||||
navigate("/employees");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to create employee");
|
||||
},
|
||||
});
|
||||
}
|
||||
19
src/domain/employees/use-cases/use-delete-employee.ts
Normal file
19
src/domain/employees/use-cases/use-delete-employee.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
import { employeesApi } from "@/domain/employees/api/employees-api";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export function useDeleteEmployee(): UseMutationResult<void, Error, string | number> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string | number) => employeesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.employees.all });
|
||||
toast.success("Employee deleted successfully!");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to delete employee");
|
||||
},
|
||||
});
|
||||
}
|
||||
13
src/domain/employees/use-cases/use-employee-detail.ts
Normal file
13
src/domain/employees/use-cases/use-employee-detail.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { employeesApi } from "@/domain/employees/api/employees-api";
|
||||
import type { Employee } from "@/domain/employees/types/employee";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import { requireQueryParam } from "@/lib/query/require-query-param";
|
||||
|
||||
export function useEmployeeDetail(id: string | undefined): UseQueryResult<Employee | null, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.employees.detail(id ?? ""),
|
||||
queryFn: () => employeesApi.getById(requireQueryParam(id, "Employee id")),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
14
src/domain/employees/use-cases/use-employees-list.ts
Normal file
14
src/domain/employees/use-cases/use-employees-list.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { employeesApi } from "@/domain/employees/api/employees-api";
|
||||
import type { EmployeesListParams, EmployeesListResult } from "@/domain/employees/types/employee";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export function useEmployeesList(
|
||||
params: EmployeesListParams,
|
||||
): UseQueryResult<EmployeesListResult, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.employees.list(params as Record<string, unknown>),
|
||||
queryFn: () => employeesApi.getList(params),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
35
src/domain/employees/use-cases/use-update-employee.ts
Normal file
35
src/domain/employees/use-cases/use-update-employee.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { employeesApi } from "@/domain/employees/api/employees-api";
|
||||
import type { EmployeeFormValues } from "@/domain/employees/schemas/employee-schema";
|
||||
import type { Employee } from "@/domain/employees/types/employee";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
interface UpdateEmployeeInput {
|
||||
id: string | number;
|
||||
values: EmployeeFormValues;
|
||||
}
|
||||
|
||||
export function useUpdateEmployee(): UseMutationResult<
|
||||
Employee | null,
|
||||
Error,
|
||||
UpdateEmployeeInput
|
||||
> {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, values }: UpdateEmployeeInput) =>
|
||||
employeesApi.update(id, values as Employee),
|
||||
onSuccess: (_data, variables) => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.employees.all });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.employees.detail(variables.id) });
|
||||
toast.success("Employee updated successfully!");
|
||||
navigate("/employees");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update employee");
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue