mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 06:59:01 +00:00
fix(employees,pm-schedules): align forms with backend API contracts
Align PM Schedule form and save payload with PMSchedule_DTO fields. Bind employee Job Title select to jobTitleId for create/update payloads. Add regression tests for both flows.
This commit is contained in:
parent
708d946399
commit
2a210eeab0
17 changed files with 657 additions and 222 deletions
|
|
@ -17,6 +17,10 @@ import {
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
resolveJobTitleId,
|
||||||
|
resolveJobTitleLabel,
|
||||||
|
} from "@/domain/employees/lib/resolve-job-title-id";
|
||||||
import {
|
import {
|
||||||
employeeDefaultValues,
|
employeeDefaultValues,
|
||||||
employeeSchema,
|
employeeSchema,
|
||||||
|
|
@ -25,6 +29,7 @@ import {
|
||||||
import { useCreateEmployee } from "@/domain/employees/use-cases/use-create-employee";
|
import { useCreateEmployee } from "@/domain/employees/use-cases/use-create-employee";
|
||||||
import { useEmployeeDetail } from "@/domain/employees/use-cases/use-employee-detail";
|
import { useEmployeeDetail } from "@/domain/employees/use-cases/use-employee-detail";
|
||||||
import { useUpdateEmployee } from "@/domain/employees/use-cases/use-update-employee";
|
import { useUpdateEmployee } from "@/domain/employees/use-cases/use-update-employee";
|
||||||
|
import { JobTitleSelect } from "@/app/(protected)/employees/_components/job-title-select";
|
||||||
|
|
||||||
const DEPARTMENTS = ["Administration", "Sales", "Service", "Field Service"] as const;
|
const DEPARTMENTS = ["Administration", "Sales", "Service", "Field Service"] as const;
|
||||||
const TIME_ZONES = [
|
const TIME_ZONES = [
|
||||||
|
|
@ -33,13 +38,6 @@ const TIME_ZONES = [
|
||||||
"Mountain Standard Time",
|
"Mountain Standard Time",
|
||||||
"Pacific Standard Time",
|
"Pacific Standard Time",
|
||||||
] as const;
|
] as const;
|
||||||
const JOB_TITLES = [
|
|
||||||
"Technician",
|
|
||||||
"Service Associate",
|
|
||||||
"Accountant",
|
|
||||||
"Salesperson",
|
|
||||||
"President",
|
|
||||||
] as const;
|
|
||||||
const ROLES = [
|
const ROLES = [
|
||||||
"Admin",
|
"Admin",
|
||||||
"Administrator User",
|
"Administrator User",
|
||||||
|
|
@ -110,6 +108,7 @@ export default function EmployeeFormPage() {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
control,
|
control,
|
||||||
|
setValue,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<EmployeeFormValues>({
|
} = useForm<EmployeeFormValues>({
|
||||||
resolver: zodResolver(employeeSchema),
|
resolver: zodResolver(employeeSchema),
|
||||||
|
|
@ -118,14 +117,17 @@ export default function EmployeeFormPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (employee && isEdit) {
|
if (employee && isEdit) {
|
||||||
|
const jobTitleId = resolveJobTitleId(employee.jobTitleId, employee.jobTitle);
|
||||||
|
const jobTitle = resolveJobTitleLabel(jobTitleId, employee.jobTitle);
|
||||||
|
|
||||||
reset({
|
reset({
|
||||||
firstName: employee.firstName,
|
firstName: employee.firstName,
|
||||||
middleName: employee.middleName,
|
middleName: employee.middleName,
|
||||||
lastName: employee.lastName,
|
lastName: employee.lastName,
|
||||||
email: employee.email,
|
email: employee.email,
|
||||||
phone: employee.phone,
|
phone: employee.phone,
|
||||||
jobTitle: employee.jobTitle,
|
jobTitle,
|
||||||
jobTitleId: employee.jobTitleId ?? null,
|
jobTitleId,
|
||||||
role: employee.role,
|
role: employee.role,
|
||||||
status: employee.status,
|
status: employee.status,
|
||||||
hireDate: employee.hireDate,
|
hireDate: employee.hireDate,
|
||||||
|
|
@ -245,12 +247,7 @@ export default function EmployeeFormPage() {
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
<SelectField
|
<JobTitleSelect control={control} setValue={setValue} />
|
||||||
name="jobTitle"
|
|
||||||
label="Job Title"
|
|
||||||
control={control}
|
|
||||||
options={JOB_TITLES}
|
|
||||||
/>
|
|
||||||
<TextField {...register("employeeNumber")} label="Employee #" fullWidth />
|
<TextField {...register("employeeNumber")} label="Employee #" fullWidth />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { Controller, type Control, type UseFormSetValue } from "react-hook-form";
|
||||||
|
import { FormControl, InputLabel, MenuItem, Select } from "@mui/material";
|
||||||
|
import { JOB_TITLE_OPTIONS } from "@/domain/employees/constants/job-title-options";
|
||||||
|
import type { EmployeeFormValues } from "@/domain/employees/schemas/employee-schema";
|
||||||
|
|
||||||
|
interface JobTitleSelectProps {
|
||||||
|
control: Control<EmployeeFormValues>;
|
||||||
|
setValue: UseFormSetValue<EmployeeFormValues>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JobTitleSelect({ control, setValue }: JobTitleSelectProps) {
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name="jobTitleId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel id="jobTitleId-label">Job Title</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="jobTitleId-label"
|
||||||
|
label="Job Title"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = String(event.target.value);
|
||||||
|
const nextId = value === "" ? null : Number(value);
|
||||||
|
field.onChange(nextId);
|
||||||
|
const label = JOB_TITLE_OPTIONS.find((option) => option.id === nextId)?.label ?? "";
|
||||||
|
setValue("jobTitle", label, { shouldDirty: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">
|
||||||
|
<em>Select...</em>
|
||||||
|
</MenuItem>
|
||||||
|
{JOB_TITLE_OPTIONS.map((option) => (
|
||||||
|
<MenuItem key={option.id} value={option.id}>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,39 +8,38 @@ import {
|
||||||
Button,
|
Button,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormControlLabel,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Paper,
|
Paper,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
|
Switch,
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
|
pmScheduleDefaultValues,
|
||||||
pmScheduleSchema,
|
pmScheduleSchema,
|
||||||
type PmScheduleFormValues,
|
type PmScheduleFormValues,
|
||||||
} from "@/domain/pm-schedules/schemas/pm-schedule-schema";
|
} from "@/domain/pm-schedules/schemas/pm-schedule-schema";
|
||||||
import { usePmScheduleDetail } from "@/domain/pm-schedules/use-cases/use-pm-schedule-detail";
|
import { usePmScheduleDetail } from "@/domain/pm-schedules/use-cases/use-pm-schedule-detail";
|
||||||
|
import { usePmScheduleFormOptions } from "@/domain/pm-schedules/use-cases/use-pm-schedule-form-options";
|
||||||
import { useSavePmSchedule } from "@/domain/pm-schedules/use-cases/use-save-pm-schedule";
|
import { useSavePmSchedule } from "@/domain/pm-schedules/use-cases/use-save-pm-schedule";
|
||||||
import { toDateInputValue } from "@/lib/time-utils";
|
import { toDateInputValue } from "@/lib/time-utils";
|
||||||
|
|
||||||
const defaultValues: PmScheduleFormValues = {
|
|
||||||
asset: "",
|
|
||||||
assetName: "",
|
|
||||||
frequency: "Quarterly",
|
|
||||||
lastServiceDate: "",
|
|
||||||
nextServiceDate: "",
|
|
||||||
status: "Active",
|
|
||||||
assignedTo: "",
|
|
||||||
notes: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PmScheduleFormPage() {
|
export default function PmScheduleFormPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isEdit = Boolean(id);
|
const isEdit = Boolean(id);
|
||||||
|
|
||||||
const { data: schedule, isLoading, error } = usePmScheduleDetail(id);
|
const { data: schedule, isLoading, error } = usePmScheduleDetail(id);
|
||||||
|
const {
|
||||||
|
assets,
|
||||||
|
locations,
|
||||||
|
isLoading: optionsLoading,
|
||||||
|
error: optionsError,
|
||||||
|
} = usePmScheduleFormOptions();
|
||||||
const savePmSchedule = useSavePmSchedule(id);
|
const savePmSchedule = useSavePmSchedule(id);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -51,25 +50,23 @@ export default function PmScheduleFormPage() {
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<PmScheduleFormValues>({
|
} = useForm<PmScheduleFormValues>({
|
||||||
resolver: zodResolver(pmScheduleSchema),
|
resolver: zodResolver(pmScheduleSchema),
|
||||||
defaultValues,
|
defaultValues: pmScheduleDefaultValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (schedule && isEdit) {
|
if (schedule && isEdit) {
|
||||||
reset({
|
reset({
|
||||||
asset: schedule.asset,
|
name: schedule.name,
|
||||||
assetName: schedule.assetName,
|
|
||||||
frequency: schedule.frequency as PmScheduleFormValues["frequency"],
|
frequency: schedule.frequency as PmScheduleFormValues["frequency"],
|
||||||
lastServiceDate: toDateInputValue(schedule.lastServiceDate),
|
startDate: toDateInputValue(schedule.startDate),
|
||||||
nextServiceDate: toDateInputValue(schedule.nextServiceDate),
|
isActive: schedule.isActive,
|
||||||
status: schedule.status as PmScheduleFormValues["status"],
|
assetId: schedule.assetId,
|
||||||
assignedTo: schedule.assignedTo,
|
locationId: schedule.locationId,
|
||||||
notes: schedule.notes,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [schedule, isEdit, reset]);
|
}, [schedule, isEdit, reset]);
|
||||||
|
|
||||||
if (isEdit && isLoading) {
|
if ((isEdit && isLoading) || optionsLoading) {
|
||||||
return (
|
return (
|
||||||
<Box className="flex justify-center p-10">
|
<Box className="flex justify-center p-10">
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
|
|
@ -84,27 +81,22 @@ export default function PmScheduleFormPage() {
|
||||||
<Typography variant="h5">{isEdit ? "Edit PM Schedule" : "Create PM Schedule"}</Typography>
|
<Typography variant="h5">{isEdit ? "Edit PM Schedule" : "Create PM Schedule"}</Typography>
|
||||||
|
|
||||||
{error && <Alert severity="error">Failed to load PM schedule</Alert>}
|
{error && <Alert severity="error">Failed to load PM schedule</Alert>}
|
||||||
|
{optionsError && (
|
||||||
|
<Alert severity="error">
|
||||||
|
{optionsError instanceof Error ? optionsError.message : "Failed to load form options"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box component="form" onSubmit={handleSubmit((values) => savePmSchedule.mutate(values))}>
|
<Box component="form" onSubmit={handleSubmit((values) => savePmSchedule.mutate(values))}>
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
<TextField
|
||||||
<TextField
|
label="Name"
|
||||||
label="Asset ID"
|
required
|
||||||
required
|
fullWidth
|
||||||
fullWidth
|
error={Boolean(errors.name)}
|
||||||
error={Boolean(errors.asset)}
|
helperText={errors.name?.message}
|
||||||
helperText={errors.asset?.message}
|
{...register("name")}
|
||||||
{...register("asset")}
|
/>
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Asset Name"
|
|
||||||
required
|
|
||||||
fullWidth
|
|
||||||
error={Boolean(errors.assetName)}
|
|
||||||
helperText={errors.assetName?.message}
|
|
||||||
{...register("assetName")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
name="frequency"
|
name="frequency"
|
||||||
|
|
@ -125,48 +117,81 @@ export default function PmScheduleFormPage() {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
<TextField
|
||||||
<TextField
|
label="Start Date"
|
||||||
label="Last Service Date"
|
type="date"
|
||||||
type="date"
|
required
|
||||||
required
|
fullWidth
|
||||||
fullWidth
|
InputLabelProps={{ shrink: true }}
|
||||||
InputLabelProps={{ shrink: true }}
|
error={Boolean(errors.startDate)}
|
||||||
error={Boolean(errors.lastServiceDate)}
|
helperText={errors.startDate?.message}
|
||||||
helperText={errors.lastServiceDate?.message}
|
{...register("startDate")}
|
||||||
{...register("lastServiceDate")}
|
/>
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Next Service Date"
|
|
||||||
type="date"
|
|
||||||
required
|
|
||||||
fullWidth
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
error={Boolean(errors.nextServiceDate)}
|
|
||||||
helperText={errors.nextServiceDate?.message}
|
|
||||||
{...register("nextServiceDate")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
<Controller
|
||||||
<TextField label="Assigned To" fullWidth {...register("assignedTo")} />
|
name="assetId"
|
||||||
<Controller
|
control={control}
|
||||||
name="status"
|
render={({ field }) => (
|
||||||
control={control}
|
<FormControl fullWidth>
|
||||||
render={({ field }) => (
|
<InputLabel>Asset</InputLabel>
|
||||||
<FormControl fullWidth>
|
<Select
|
||||||
<InputLabel>Status</InputLabel>
|
label="Asset"
|
||||||
<Select label="Status" value={field.value} onChange={field.onChange}>
|
value={field.value ?? ""}
|
||||||
<MenuItem value="Active">Active</MenuItem>
|
onChange={(event) => {
|
||||||
<MenuItem value="Inactive">Inactive</MenuItem>
|
const value = String(event.target.value);
|
||||||
<MenuItem value="Suspended">Suspended</MenuItem>
|
field.onChange(value === "" ? null : Number(value));
|
||||||
</Select>
|
}}
|
||||||
</FormControl>
|
>
|
||||||
)}
|
<MenuItem value="">
|
||||||
/>
|
<em>None</em>
|
||||||
</Stack>
|
</MenuItem>
|
||||||
|
{assets.map((asset) => (
|
||||||
|
<MenuItem key={String(asset.id)} value={asset.id}>
|
||||||
|
{asset.name || asset.assetCode}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<TextField label="Notes" fullWidth multiline rows={4} {...register("notes")} />
|
<Controller
|
||||||
|
name="locationId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Location</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Location"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = String(event.target.value);
|
||||||
|
field.onChange(value === "" ? null : Number(value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">
|
||||||
|
<em>None</em>
|
||||||
|
</MenuItem>
|
||||||
|
{locations.map((location) => (
|
||||||
|
<MenuItem key={String(location.id)} value={location.id}>
|
||||||
|
{location.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="isActive"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={field.value} onChange={field.onChange} />}
|
||||||
|
label="Active"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||||
<Button variant="outlined" onClick={() => navigate("/pmschedules")}>
|
<Button variant="outlined" onClick={() => navigate("/pmschedules")}>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Chip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
|
|
@ -107,12 +108,12 @@ export default function PmSchedulesListPage() {
|
||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-semibold">Asset</TableCell>
|
<TableCell className="font-semibold">Name</TableCell>
|
||||||
<TableCell className="font-semibold">Frequency</TableCell>
|
<TableCell className="font-semibold">Frequency</TableCell>
|
||||||
<TableCell className="font-semibold">Last Date</TableCell>
|
<TableCell className="font-semibold">Start Date</TableCell>
|
||||||
<TableCell className="font-semibold">Next Date</TableCell>
|
|
||||||
<TableCell className="font-semibold">Status</TableCell>
|
<TableCell className="font-semibold">Status</TableCell>
|
||||||
<TableCell className="font-semibold">Assigned To</TableCell>
|
<TableCell className="font-semibold">Asset</TableCell>
|
||||||
|
<TableCell className="font-semibold">Location</TableCell>
|
||||||
<TableCell align="right" className="font-semibold">
|
<TableCell align="right" className="font-semibold">
|
||||||
Actions
|
Actions
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -141,14 +142,21 @@ export default function PmSchedulesListPage() {
|
||||||
className="font-semibold"
|
className="font-semibold"
|
||||||
onClick={() => navigate(`/pmschedules/edit/${row.id}`)}
|
onClick={() => navigate(`/pmschedules/edit/${row.id}`)}
|
||||||
>
|
>
|
||||||
{row.asset || row.name || "—"}
|
{row.name || "—"}
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{row.frequency || "—"}</TableCell>
|
<TableCell>{row.frequency || "—"}</TableCell>
|
||||||
<TableCell>{row.lastDate || "—"}</TableCell>
|
<TableCell>{row.startDate || "—"}</TableCell>
|
||||||
<TableCell>{row.nextDate || "—"}</TableCell>
|
<TableCell>
|
||||||
<TableCell>{row.status || "—"}</TableCell>
|
<Chip
|
||||||
<TableCell>{row.assignedTo || "—"}</TableCell>
|
label={row.isActive ? "Active" : "Inactive"}
|
||||||
|
size="small"
|
||||||
|
color={row.isActive ? "success" : "default"}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.asset || "—"}</TableCell>
|
||||||
|
<TableCell>{row.location || "—"}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
<Tooltip title="Edit">
|
<Tooltip title="Edit">
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|
@ -193,8 +201,8 @@ export default function PmSchedulesListPage() {
|
||||||
<DialogTitle>Delete PM Schedule</DialogTitle>
|
<DialogTitle>Delete PM Schedule</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText>
|
<DialogContentText>
|
||||||
Are you sure you want to delete "{deleteTarget?.asset || deleteTarget?.name}"?
|
Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be
|
||||||
This action cannot be undone.
|
undone.
|
||||||
</DialogContentText>
|
</DialogContentText>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
|
|
|
||||||
31
src/domain/employees/constants/job-title-options.ts
Normal file
31
src/domain/employees/constants/job-title-options.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
export interface JobTitleOption {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static job title options — IDs must match the JobTitles table in the target environment.
|
||||||
|
* No backend lookup endpoint exists yet (frontend-only scope).
|
||||||
|
*/
|
||||||
|
export const JOB_TITLE_OPTIONS: JobTitleOption[] = [
|
||||||
|
{ id: 1, label: "Technician" },
|
||||||
|
{ id: 2, label: "Service Associate" },
|
||||||
|
{ id: 3, label: "Accountant" },
|
||||||
|
{ id: 4, label: "Salesperson" },
|
||||||
|
{ id: 5, label: "President" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function findJobTitleLabelById(id: number | null | undefined): string {
|
||||||
|
if (id == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return JOB_TITLE_OPTIONS.find((option) => option.id === id)?.label ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findJobTitleIdByLabel(label: string | null | undefined): number | null {
|
||||||
|
if (!label?.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = label.trim().toLowerCase();
|
||||||
|
return JOB_TITLE_OPTIONS.find((option) => option.label.toLowerCase() === normalized)?.id ?? null;
|
||||||
|
}
|
||||||
24
src/domain/employees/lib/resolve-job-title-id.ts
Normal file
24
src/domain/employees/lib/resolve-job-title-id.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import {
|
||||||
|
findJobTitleIdByLabel,
|
||||||
|
findJobTitleLabelById,
|
||||||
|
} from "@/domain/employees/constants/job-title-options";
|
||||||
|
|
||||||
|
export function resolveJobTitleId(
|
||||||
|
jobTitleId: number | null | undefined,
|
||||||
|
jobTitleLabel: string | null | undefined,
|
||||||
|
): number | null {
|
||||||
|
if (typeof jobTitleId === "number" && jobTitleId > 0) {
|
||||||
|
return jobTitleId;
|
||||||
|
}
|
||||||
|
return findJobTitleIdByLabel(jobTitleLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveJobTitleLabel(
|
||||||
|
jobTitleId: number | null | undefined,
|
||||||
|
jobTitleLabel: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
if (jobTitleLabel?.trim()) {
|
||||||
|
return jobTitleLabel;
|
||||||
|
}
|
||||||
|
return findJobTitleLabelById(jobTitleId);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { API_PATHS } from "@/api/api-paths";
|
import { API_PATHS } from "@/api/api-paths";
|
||||||
import { apiGet, apiPostNoContent } from "@/api/api";
|
import { apiGet, apiPostNoContent } from "@/api/api";
|
||||||
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
import { handleApiResponse } from "@/api/handle-api-response";
|
||||||
import {
|
import {
|
||||||
mapPmSchedule,
|
mapPmSchedule,
|
||||||
mapPmScheduleListItem,
|
mapPmScheduleListItem,
|
||||||
|
|
@ -12,26 +12,39 @@ import type {
|
||||||
PmSchedulesListResult,
|
PmSchedulesListResult,
|
||||||
} from "@/domain/pm-schedules/types/pm-schedule";
|
} from "@/domain/pm-schedules/types/pm-schedule";
|
||||||
|
|
||||||
export const pmSchedulesApi = {
|
function filterBySearch(items: ReturnType<typeof mapPmScheduleListItem>[], search: string) {
|
||||||
getList: async (params: PmSchedulesListParams = {}): Promise<PmSchedulesListResult> => {
|
const term = search.trim().toLowerCase();
|
||||||
const data = await apiGet<unknown>(API_PATHS.pmSchedule.getList, {
|
if (!term) {
|
||||||
searchParams: {
|
return items;
|
||||||
search: params.search ?? "",
|
}
|
||||||
page: params.page ?? 1,
|
|
||||||
pageSize: params.pageSize ?? 12,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const paginated = handlePaginatedResponse<unknown>(data, {
|
return items.filter((item) =>
|
||||||
page: params.page,
|
[item.name, item.frequency, item.asset, item.location].join(" ").toLowerCase().includes(term),
|
||||||
pageSize: params.pageSize,
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
function paginateClientSide<T>(items: T[], page: number, pageSize: number) {
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
return items.slice(start, start + pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pmSchedulesApi = {
|
||||||
|
/** Backend GetList returns a full array (no server pagination). */
|
||||||
|
getList: async (params: PmSchedulesListParams = {}): Promise<PmSchedulesListResult> => {
|
||||||
|
const page = params.page ?? 1;
|
||||||
|
const pageSize = params.pageSize ?? 12;
|
||||||
|
|
||||||
|
const data = await apiGet<unknown>(API_PATHS.pmSchedule.getList);
|
||||||
|
const rawItems = handleApiResponse<unknown[]>(data);
|
||||||
|
const allItems = Array.isArray(rawItems) ? rawItems.map(mapPmScheduleListItem) : [];
|
||||||
|
const filtered = filterBySearch(allItems, params.search ?? "");
|
||||||
|
const items = paginateClientSide(filtered, page, pageSize);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: paginated.items.map(mapPmScheduleListItem),
|
items,
|
||||||
totalCount: paginated.totalCount,
|
totalCount: filtered.length,
|
||||||
page: paginated.page,
|
page,
|
||||||
pageSize: paginated.pageSize,
|
pageSize,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -43,23 +56,9 @@ export const pmSchedulesApi = {
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Create and update both use PMSchedule/Save — Id=0 for create. */
|
/** Create and update both use PMSchedule/Save — Id=0 for create. */
|
||||||
save: async (schedule: Partial<PmSchedule> & { id?: number }): Promise<PmSchedule> => {
|
save: async (schedule: Partial<PmSchedule> & { id?: number }): Promise<void> => {
|
||||||
const payload = mapPmScheduleToSavePayload(schedule);
|
const payload = mapPmScheduleToSavePayload(schedule);
|
||||||
await apiPostNoContent(API_PATHS.pmSchedule.save, payload);
|
await apiPostNoContent(API_PATHS.pmSchedule.save, payload);
|
||||||
|
|
||||||
if (schedule.id && schedule.id > 0) {
|
|
||||||
return pmSchedulesApi.getById(schedule.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapPmSchedule({
|
|
||||||
Id: 0,
|
|
||||||
Name: payload.Name,
|
|
||||||
Frequency: payload.Frequency,
|
|
||||||
StartDate: payload.StartDate,
|
|
||||||
IsActive: payload.IsActive,
|
|
||||||
AssetId: payload.AssetId,
|
|
||||||
LocationId: payload.LocationId,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: async (id: string | number): Promise<void> => {
|
delete: async (id: string | number): Promise<void> => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import type { PmSchedule, PmScheduleListItem } from "@/domain/pm-schedules/types/pm-schedule";
|
import type {
|
||||||
|
PmSchedule,
|
||||||
|
PmScheduleListItem,
|
||||||
|
PmScheduleSavePayload,
|
||||||
|
} from "@/domain/pm-schedules/types/pm-schedule";
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
@ -10,6 +14,9 @@ function readString(record: Record<string, unknown>, ...keys: string[]): string
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
@ -20,87 +27,76 @@ function readNumber(record: Record<string, unknown>, ...keys: string[]): number
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
if (typeof value === "string" && value !== "") {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readBoolean(record: Record<string, unknown>, ...keys: string[]): boolean {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateOnly(value: string): string {
|
||||||
|
if (!value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value.split("T")[0] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
export function mapPmScheduleListItem(raw: unknown): PmScheduleListItem {
|
export function mapPmScheduleListItem(raw: unknown): PmScheduleListItem {
|
||||||
const record = isRecord(raw) ? raw : {};
|
const record = isRecord(raw) ? raw : {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: readNumber(record, "id", "Id") ?? 0,
|
id: readNumber(record, "id", "Id") ?? 0,
|
||||||
asset: readString(record, "asset", "Asset", "assetCode", "AssetCode", "name", "Name"),
|
name: readString(record, "name", "Name"),
|
||||||
frequency: readString(record, "frequency", "Frequency"),
|
frequency: readString(record, "frequency", "Frequency"),
|
||||||
lastDate: readString(record, "lastDate", "LastDate", "lastServiceDate", "LastServiceDate"),
|
startDate: toDateOnly(readString(record, "startDate", "StartDate")),
|
||||||
nextDate: readString(
|
isActive: readBoolean(record, "isActive", "IsActive"),
|
||||||
record,
|
asset: readString(record, "asset", "Asset"),
|
||||||
"nextDate",
|
location: readString(record, "location", "Location"),
|
||||||
"NextDate",
|
|
||||||
"nextServiceDate",
|
|
||||||
"NextServiceDate",
|
|
||||||
"startDate",
|
|
||||||
"StartDate",
|
|
||||||
),
|
|
||||||
status: readString(record, "status", "Status", "isActive", "IsActive") || "Active",
|
|
||||||
assignedTo: readString(record, "assignedTo", "AssignedTo"),
|
|
||||||
name: readString(record, "name", "Name", "assetName", "AssetName"),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapPmSchedule(raw: unknown): PmSchedule {
|
export function mapPmSchedule(raw: unknown): PmSchedule {
|
||||||
const record = isRecord(raw) ? raw : {};
|
const record = isRecord(raw) ? raw : {};
|
||||||
const listItem = mapPmScheduleListItem(raw);
|
const listItem = mapPmScheduleListItem(raw);
|
||||||
const isActive = record.isActive ?? record.IsActive;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: listItem.id,
|
id: listItem.id,
|
||||||
asset: readString(record, "asset", "Asset", "assetCode", "AssetCode") || listItem.asset,
|
name: listItem.name,
|
||||||
assetName:
|
frequency: listItem.frequency || "Quarterly",
|
||||||
readString(record, "assetName", "AssetName", "name", "Name") ||
|
startDate: listItem.startDate,
|
||||||
listItem.name ||
|
isActive: listItem.isActive,
|
||||||
listItem.asset,
|
|
||||||
assetId: readNumber(record, "assetId", "AssetId"),
|
assetId: readNumber(record, "assetId", "AssetId"),
|
||||||
locationId: readNumber(record, "locationId", "LocationId"),
|
locationId: readNumber(record, "locationId", "LocationId"),
|
||||||
frequency: listItem.frequency || "Quarterly",
|
asset: listItem.asset,
|
||||||
lastServiceDate: readString(
|
location: listItem.location,
|
||||||
record,
|
|
||||||
"lastServiceDate",
|
|
||||||
"LastServiceDate",
|
|
||||||
"lastDate",
|
|
||||||
"LastDate",
|
|
||||||
),
|
|
||||||
nextServiceDate:
|
|
||||||
readString(
|
|
||||||
record,
|
|
||||||
"nextServiceDate",
|
|
||||||
"NextServiceDate",
|
|
||||||
"nextDate",
|
|
||||||
"NextDate",
|
|
||||||
"startDate",
|
|
||||||
"StartDate",
|
|
||||||
) || listItem.nextDate,
|
|
||||||
status:
|
|
||||||
typeof isActive === "boolean"
|
|
||||||
? isActive
|
|
||||||
? "Active"
|
|
||||||
: "Inactive"
|
|
||||||
: listItem.status || "Active",
|
|
||||||
assignedTo: listItem.assignedTo,
|
|
||||||
notes: readString(record, "notes", "Notes"),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unified save payload — always uses PMSchedule/Save (fixes Create vs Save inconsistency). */
|
export function mapPmScheduleToSavePayload(
|
||||||
export function mapPmScheduleToSavePayload(schedule: Partial<PmSchedule> & { id?: number }) {
|
schedule: Partial<PmSchedule> & { id?: number },
|
||||||
const isActive =
|
): PmScheduleSavePayload {
|
||||||
schedule.status === "Active" || schedule.status === undefined || schedule.status === "";
|
const startDate = schedule.startDate ?? "";
|
||||||
|
const isoStartDate = startDate.includes("T") ? startDate : `${startDate}T00:00:00`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Id: schedule.id ?? 0,
|
Id: schedule.id ?? 0,
|
||||||
Name: schedule.assetName || schedule.asset || "",
|
Name: schedule.name ?? "",
|
||||||
Frequency: schedule.frequency || "Quarterly",
|
Frequency: schedule.frequency ?? "Quarterly",
|
||||||
StartDate: schedule.nextServiceDate || "",
|
StartDate: isoStartDate,
|
||||||
IsActive: isActive,
|
IsActive: schedule.isActive ?? true,
|
||||||
AssetId: schedule.assetId ?? null,
|
AssetId: schedule.assetId ?? null,
|
||||||
LocationId: schedule.locationId ?? null,
|
LocationId: schedule.locationId ?? null,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,21 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const pmScheduleSchema = z.object({
|
export const pmScheduleSchema = z.object({
|
||||||
asset: z.string().min(1, "Asset ID is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
assetName: z.string().min(1, "Asset name is required"),
|
|
||||||
frequency: z.enum(["Daily", "Weekly", "Monthly", "Quarterly", "Semi-Annual", "Annual"]),
|
frequency: z.enum(["Daily", "Weekly", "Monthly", "Quarterly", "Semi-Annual", "Annual"]),
|
||||||
lastServiceDate: z.string().min(1, "Last service date is required"),
|
startDate: z.string().min(1, "Start date is required"),
|
||||||
nextServiceDate: z.string().min(1, "Next service date is required"),
|
isActive: z.boolean(),
|
||||||
status: z.enum(["Active", "Inactive", "Suspended"]),
|
assetId: z.number().nullable(),
|
||||||
assignedTo: z.string().optional(),
|
locationId: z.number().nullable(),
|
||||||
notes: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type PmScheduleFormValues = z.infer<typeof pmScheduleSchema>;
|
export type PmScheduleFormValues = z.infer<typeof pmScheduleSchema>;
|
||||||
|
|
||||||
|
export const pmScheduleDefaultValues: PmScheduleFormValues = {
|
||||||
|
name: "",
|
||||||
|
frequency: "Quarterly",
|
||||||
|
startDate: "",
|
||||||
|
isActive: true,
|
||||||
|
assetId: null,
|
||||||
|
locationId: null,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,23 @@
|
||||||
export interface PmScheduleListItem {
|
export interface PmScheduleListItem {
|
||||||
id: number;
|
id: number;
|
||||||
asset: string;
|
name: string;
|
||||||
frequency: string;
|
frequency: string;
|
||||||
lastDate: string;
|
startDate: string;
|
||||||
nextDate: string;
|
isActive: boolean;
|
||||||
status: string;
|
asset: string;
|
||||||
assignedTo: string;
|
location: string;
|
||||||
name?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PmSchedule {
|
export interface PmSchedule {
|
||||||
id: number;
|
id: number;
|
||||||
asset: string;
|
name: string;
|
||||||
assetName: string;
|
frequency: string;
|
||||||
|
startDate: string;
|
||||||
|
isActive: boolean;
|
||||||
assetId: number | null;
|
assetId: number | null;
|
||||||
locationId: number | null;
|
locationId: number | null;
|
||||||
frequency: string;
|
asset: string;
|
||||||
lastServiceDate: string;
|
location: string;
|
||||||
nextServiceDate: string;
|
|
||||||
status: string;
|
|
||||||
assignedTo: string;
|
|
||||||
notes: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PmSchedulesListParams {
|
export interface PmSchedulesListParams {
|
||||||
|
|
@ -35,3 +32,13 @@ export interface PmSchedulesListResult {
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PmScheduleSavePayload {
|
||||||
|
Id: number;
|
||||||
|
Name: string;
|
||||||
|
Frequency: string;
|
||||||
|
StartDate: string;
|
||||||
|
IsActive: boolean;
|
||||||
|
AssetId: number | null;
|
||||||
|
LocationId: number | null;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { assetsApi } from "@/domain/assets/api/assets-api";
|
||||||
|
import type { AssetListItem } from "@/domain/assets/types/asset";
|
||||||
|
import { locationsApi } from "@/domain/locations/api/locations-api";
|
||||||
|
import type { LocationListItem } from "@/domain/locations/types/location";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
export interface PmScheduleFormOptionsResult {
|
||||||
|
assets: AssetListItem[];
|
||||||
|
locations: LocationListItem[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePmScheduleFormOptions(): PmScheduleFormOptionsResult {
|
||||||
|
const assetsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.assets.list({ page: 1, pageSize: 1000 }),
|
||||||
|
queryFn: () => assetsApi.getList({ page: 1, pageSize: 1000 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const locationsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.locations.list({ page: 1, pageSize: 1000 }),
|
||||||
|
queryFn: () => locationsApi.getList({ page: 1, pageSize: 1000 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
assets: assetsQuery.data?.items ?? [],
|
||||||
|
locations: locationsQuery.data?.items ?? [],
|
||||||
|
isLoading: assetsQuery.isLoading || locationsQuery.isLoading,
|
||||||
|
error: assetsQuery.error ?? locationsQuery.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,12 +3,11 @@ import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { pmSchedulesApi } from "@/domain/pm-schedules/api/pm-schedules-api";
|
import { pmSchedulesApi } from "@/domain/pm-schedules/api/pm-schedules-api";
|
||||||
import type { PmScheduleFormValues } from "@/domain/pm-schedules/schemas/pm-schedule-schema";
|
import type { PmScheduleFormValues } from "@/domain/pm-schedules/schemas/pm-schedule-schema";
|
||||||
import type { PmSchedule } from "@/domain/pm-schedules/types/pm-schedule";
|
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
export function useSavePmSchedule(
|
export function useSavePmSchedule(
|
||||||
id?: string,
|
id?: string,
|
||||||
): UseMutationResult<PmSchedule, Error, PmScheduleFormValues> {
|
): UseMutationResult<void, Error, PmScheduleFormValues> {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|
@ -16,14 +15,12 @@ export function useSavePmSchedule(
|
||||||
mutationFn: (values: PmScheduleFormValues) =>
|
mutationFn: (values: PmScheduleFormValues) =>
|
||||||
pmSchedulesApi.save({
|
pmSchedulesApi.save({
|
||||||
id: id ? Number(id) : 0,
|
id: id ? Number(id) : 0,
|
||||||
asset: values.asset,
|
name: values.name,
|
||||||
assetName: values.assetName,
|
|
||||||
frequency: values.frequency,
|
frequency: values.frequency,
|
||||||
lastServiceDate: values.lastServiceDate,
|
startDate: values.startDate,
|
||||||
nextServiceDate: values.nextServiceDate,
|
isActive: values.isActive,
|
||||||
status: values.status,
|
assetId: values.assetId,
|
||||||
assignedTo: values.assignedTo,
|
locationId: values.locationId,
|
||||||
notes: values.notes,
|
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.pmSchedules.all });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.pmSchedules.all });
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { JobTitleSelect } from "@/app/(protected)/employees/_components/job-title-select";
|
||||||
|
import type { EmployeeFormValues } from "@/domain/employees/schemas/employee-schema";
|
||||||
|
import { transformToBackend } from "@/domain/employees/mappers/employee-mapper";
|
||||||
|
import type { Employee } from "@/domain/employees/types/employee";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
function JobTitleSelectHarness({
|
||||||
|
onSubmit = vi.fn(),
|
||||||
|
}: {
|
||||||
|
onSubmit?: (values: EmployeeFormValues) => void;
|
||||||
|
}) {
|
||||||
|
const { control, setValue, handleSubmit } = useForm<EmployeeFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
jobTitleId: null,
|
||||||
|
jobTitle: "",
|
||||||
|
} as EmployeeFormValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<JobTitleSelect control={control} setValue={setValue} />
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("JobTitleSelect", () => {
|
||||||
|
it("submits numeric JobTitleId when a job title is selected", async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(<JobTitleSelectHarness onSubmit={onSubmit} />, { withAuth: false });
|
||||||
|
|
||||||
|
const form = document.querySelector("form");
|
||||||
|
expect(form).not.toBeNull();
|
||||||
|
|
||||||
|
fireEvent.mouseDown(within(form!).getByLabelText("Job Title"));
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "Technician" }));
|
||||||
|
fireEvent.click(within(form!).getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
jobTitleId: 1,
|
||||||
|
jobTitle: "Technician",
|
||||||
|
}),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = transformToBackend({
|
||||||
|
firstName: "Jane",
|
||||||
|
lastName: "Doe",
|
||||||
|
jobTitleId: 1,
|
||||||
|
jobTitle: "Technician",
|
||||||
|
} as Employee);
|
||||||
|
|
||||||
|
expect(payload.JobTitleId).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
30
src/test/domain/employees/lib/resolve-job-title-id.test.ts
Normal file
30
src/test/domain/employees/lib/resolve-job-title-id.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
resolveJobTitleId,
|
||||||
|
resolveJobTitleLabel,
|
||||||
|
} from "@/domain/employees/lib/resolve-job-title-id";
|
||||||
|
|
||||||
|
describe("resolveJobTitleId", () => {
|
||||||
|
it("prefers explicit jobTitleId when present", () => {
|
||||||
|
expect(resolveJobTitleId(3, "Technician")).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to label when jobTitleId is missing", () => {
|
||||||
|
expect(resolveJobTitleId(null, "Technician")).toBe(1);
|
||||||
|
expect(resolveJobTitleId(undefined, "President")).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when label is unknown", () => {
|
||||||
|
expect(resolveJobTitleId(null, "Unknown Title")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveJobTitleLabel", () => {
|
||||||
|
it("prefers existing label from API", () => {
|
||||||
|
expect(resolveJobTitleLabel(1, "Technician")).toBe("Technician");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives label from id when label is empty", () => {
|
||||||
|
expect(resolveJobTitleLabel(2, "")).toBe("Service Associate");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -46,4 +46,16 @@ describe("transformEmployee", () => {
|
||||||
expect(employee?.jobTitleId).toBe(5);
|
expect(employee?.jobTitleId).toBe(5);
|
||||||
expect(employee?.jobTitle).toBe("Technician");
|
expect(employee?.jobTitle).toBe("Technician");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps form values with jobTitleId to backend payload", () => {
|
||||||
|
const payload = transformToBackend({
|
||||||
|
firstName: "Jane",
|
||||||
|
lastName: "Doe",
|
||||||
|
jobTitle: "Technician",
|
||||||
|
jobTitleId: 1,
|
||||||
|
} as Employee);
|
||||||
|
|
||||||
|
expect(payload.JobTitleId).toBe(1);
|
||||||
|
expect(payload).not.toHaveProperty("JobTitle");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
95
src/test/domain/pm-schedules/api/pm-schedules-api.test.ts
Normal file
95
src/test/domain/pm-schedules/api/pm-schedules-api.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { API_PATHS } from "@/api/api-paths";
|
||||||
|
|
||||||
|
const apiGet = vi.fn();
|
||||||
|
const apiPostNoContent = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/api/api", () => ({
|
||||||
|
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||||
|
apiPostNoContent: (...args: unknown[]) => apiPostNoContent(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { pmSchedulesApi } from "@/domain/pm-schedules/api/pm-schedules-api";
|
||||||
|
|
||||||
|
describe("pmSchedulesApi", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
apiGet.mockReset();
|
||||||
|
apiPostNoContent.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getList uses GetList without server pagination params", async () => {
|
||||||
|
apiGet.mockResolvedValue([
|
||||||
|
{
|
||||||
|
Id: 1,
|
||||||
|
Name: "Schedule A",
|
||||||
|
Frequency: "Weekly",
|
||||||
|
StartDate: "2026-01-01",
|
||||||
|
IsActive: true,
|
||||||
|
Asset: "Asset 1",
|
||||||
|
Location: "Loc 1",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await pmSchedulesApi.getList({ page: 1, pageSize: 12 });
|
||||||
|
|
||||||
|
expect(apiGet).toHaveBeenCalledWith(API_PATHS.pmSchedule.getList);
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]?.name).toBe("Schedule A");
|
||||||
|
expect(result.totalCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getById calls PMSchedule/GetById", async () => {
|
||||||
|
apiGet.mockResolvedValue({
|
||||||
|
Id: 2,
|
||||||
|
Name: "Detail",
|
||||||
|
Frequency: "Monthly",
|
||||||
|
StartDate: "2026-02-01",
|
||||||
|
IsActive: true,
|
||||||
|
AssetId: 5,
|
||||||
|
LocationId: 6,
|
||||||
|
});
|
||||||
|
|
||||||
|
const schedule = await pmSchedulesApi.getById(2);
|
||||||
|
|
||||||
|
expect(apiGet).toHaveBeenCalledWith(API_PATHS.pmSchedule.getById, {
|
||||||
|
searchParams: { id: 2 },
|
||||||
|
});
|
||||||
|
expect(schedule.assetId).toBe(5);
|
||||||
|
expect(schedule.locationId).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("save posts to PMSchedule/Save without re-fetching GetById", async () => {
|
||||||
|
apiPostNoContent.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await pmSchedulesApi.save({
|
||||||
|
id: 3,
|
||||||
|
name: "Updated",
|
||||||
|
frequency: "Quarterly",
|
||||||
|
startDate: "2026-03-01",
|
||||||
|
isActive: true,
|
||||||
|
assetId: 1,
|
||||||
|
locationId: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiPostNoContent).toHaveBeenCalledWith(
|
||||||
|
API_PATHS.pmSchedule.save,
|
||||||
|
expect.objectContaining({
|
||||||
|
Id: 3,
|
||||||
|
Name: "Updated",
|
||||||
|
AssetId: 1,
|
||||||
|
LocationId: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(apiGet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delete posts to PMSchedule/Delete", async () => {
|
||||||
|
apiPostNoContent.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await pmSchedulesApi.delete(9);
|
||||||
|
|
||||||
|
expect(apiPostNoContent).toHaveBeenCalledWith(API_PATHS.pmSchedule.delete, undefined, {
|
||||||
|
searchParams: { id: 9 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { mapPmScheduleToSavePayload } from "@/domain/pm-schedules/mappers/pm-schedule-mapper";
|
||||||
|
|
||||||
|
describe("mapPmScheduleToSavePayload", () => {
|
||||||
|
it("maps form fields to PMSchedule_DTO shape", () => {
|
||||||
|
const payload = mapPmScheduleToSavePayload({
|
||||||
|
id: 0,
|
||||||
|
name: "Quarterly HVAC",
|
||||||
|
frequency: "Quarterly",
|
||||||
|
startDate: "2026-06-16",
|
||||||
|
isActive: true,
|
||||||
|
assetId: 10,
|
||||||
|
locationId: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload).toEqual({
|
||||||
|
Id: 0,
|
||||||
|
Name: "Quarterly HVAC",
|
||||||
|
Frequency: "Quarterly",
|
||||||
|
StartDate: "2026-06-16T00:00:00",
|
||||||
|
IsActive: true,
|
||||||
|
AssetId: 10,
|
||||||
|
LocationId: 20,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes Id for updates", () => {
|
||||||
|
const payload = mapPmScheduleToSavePayload({
|
||||||
|
id: 42,
|
||||||
|
name: "Updated",
|
||||||
|
frequency: "Monthly",
|
||||||
|
startDate: "2026-07-01",
|
||||||
|
isActive: false,
|
||||||
|
assetId: 5,
|
||||||
|
locationId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.Id).toBe(42);
|
||||||
|
expect(payload.IsActive).toBe(false);
|
||||||
|
expect(payload.LocationId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapPmScheduleListItem", () => {
|
||||||
|
it("maps backend GetList fields only", async () => {
|
||||||
|
const { mapPmScheduleListItem } =
|
||||||
|
await import("@/domain/pm-schedules/mappers/pm-schedule-mapper");
|
||||||
|
|
||||||
|
const item = mapPmScheduleListItem({
|
||||||
|
Id: 1,
|
||||||
|
Name: "Schedule A",
|
||||||
|
Frequency: "Weekly",
|
||||||
|
StartDate: "2026-01-15T00:00:00",
|
||||||
|
IsActive: true,
|
||||||
|
Asset: "Asset 1",
|
||||||
|
Location: "Site 1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item).toEqual({
|
||||||
|
id: 1,
|
||||||
|
name: "Schedule A",
|
||||||
|
frequency: "Weekly",
|
||||||
|
startDate: "2026-01-15",
|
||||||
|
isActive: true,
|
||||||
|
asset: "Asset 1",
|
||||||
|
location: "Site 1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue