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:
Arthur Bassi 2026-06-16 14:50:42 -03:00
parent 708d946399
commit 2a210eeab0
17 changed files with 657 additions and 222 deletions

View file

@ -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}>

View file

@ -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>
)}
/>
);
}

View file

@ -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")}>

View file

@ -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 &quot;{deleteTarget?.asset || deleteTarget?.name}&quot;? Are you sure you want to delete &quot;{deleteTarget?.name}&quot;? This action cannot be
This action cannot be undone. undone.
</DialogContentText> </DialogContentText>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>

View 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;
}

View 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);
}

View file

@ -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> => {

View file

@ -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,
}; };

View file

@ -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,
};

View file

@ -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;
}

View file

@ -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,
};
}

View file

@ -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 });

View file

@ -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);
});
});

View 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");
});
});

View file

@ -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");
});
}); });

View 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 },
});
});
});

View file

@ -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",
});
});
});