merge(work-orders): sync with platform-polish tip

This commit is contained in:
arthur.bassi 2026-08-10 19:07:45 -03:00
commit 55449a9e31
14 changed files with 355 additions and 16 deletions

View file

@ -15,7 +15,9 @@ export interface VendorDeactivationState {
confirm: () => void; confirm: () => void;
} }
export function useVendorDeactivation(): VendorDeactivationState { export function useVendorDeactivation(options?: {
onSuccess?: () => void;
}): VendorDeactivationState {
const [target, setTarget] = useState<VendorListItem | null>(null); const [target, setTarget] = useState<VendorListItem | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const deleteVendor = useDeleteVendor(); const deleteVendor = useDeleteVendor();
@ -39,7 +41,10 @@ export function useVendorDeactivation(): VendorDeactivationState {
if (!target || target.id == null) return; if (!target || target.id == null) return;
setError(null); setError(null);
deleteVendor.mutate(target.id, { deleteVendor.mutate(target.id, {
onSuccess: () => setTarget(null), onSuccess: () => {
setTarget(null);
options?.onSuccess?.();
},
onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"), onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"),
}); });
}; };

View file

@ -20,6 +20,24 @@ import type {
VendorRosterConflict, VendorRosterConflict,
} from "@/domain/vendors/types/vendor"; } from "@/domain/vendors/types/vendor";
function withoutBlankNewTechnicians(
values: VendorCompanyRosterFormValues,
): VendorCompanyRosterFormValues {
return {
...values,
technicians: values.technicians.filter(
(technician) =>
technician.id != null ||
Boolean(
technician.contactName.trim() ||
technician.phone.trim() ||
technician.email.trim() ||
technician.tradeSpecialties.trim(),
),
),
};
}
export interface VendorRosterFormProps { export interface VendorRosterFormProps {
mode: "create" | "update"; mode: "create" | "update";
vendorId?: string | number; vendorId?: string | number;
@ -109,7 +127,7 @@ export function useVendorRosterForm({
save.mutate( save.mutate(
{ {
mode: isUpdate ? "update" : "create", mode: isUpdate ? "update" : "create",
values: formValues, values: withoutBlankNewTechnicians(formValues),
companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined, companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined,
rowVersion: isUpdate ? committedRoster?.rowVersion : undefined, rowVersion: isUpdate ? committedRoster?.rowVersion : undefined,
originalRoster: mode === "update" ? routeRoster : undefined, originalRoster: mode === "update" ? routeRoster : undefined,

View file

@ -1,4 +1,10 @@
import { Controller, useFieldArray, type Control, type FieldErrors } from "react-hook-form"; import {
Controller,
useFieldArray,
useWatch,
type Control,
type FieldErrors,
} from "react-hook-form";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import { import {
@ -28,6 +34,7 @@ interface VendorRosterFormFieldsProps {
selectedCompanyId?: string | number | null; selectedCompanyId?: string | number | null;
onSelectCompany?: (company: VendorFacetCompany | null) => Promise<void>; onSelectCompany?: (company: VendorFacetCompany | null) => Promise<void>;
onClearSelectedCompany?: (nextName?: string) => void; onClearSelectedCompany?: (nextName?: string) => void;
onRequestDeactivate?: (technician: RosterTechnicianValues) => void;
} }
function CompanyNameField({ function CompanyNameField({
@ -221,6 +228,7 @@ interface TechnicianRowProps {
onRemove: () => void; onRemove: () => void;
canRemove: boolean; canRemove: boolean;
tradeOptions: string[]; tradeOptions: string[];
onRequestDeactivate?: (technician: RosterTechnicianValues) => void;
} }
function TechnicianRow({ function TechnicianRow({
@ -230,7 +238,10 @@ function TechnicianRow({
onRemove, onRemove,
canRemove, canRemove,
tradeOptions, tradeOptions,
onRequestDeactivate,
}: TechnicianRowProps) { }: TechnicianRowProps) {
const technician = useWatch({ control, name: `technicians.${index}` });
return ( return (
<Stack spacing={2} className="rounded-md border border-solid border-gray-200 p-3"> <Stack spacing={2} className="rounded-md border border-solid border-gray-200 p-3">
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}> <Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
@ -305,7 +316,13 @@ function TechnicianRow({
control={ control={
<Switch <Switch
checked={field.value} checked={field.value}
onChange={(_event, checked) => field.onChange(checked)} onChange={(_event, checked) => {
if (!checked && technician?.id != null && onRequestDeactivate != null) {
onRequestDeactivate(technician);
return;
}
field.onChange(checked);
}}
/> />
} }
label={field.value ? "Active" : "Inactive"} label={field.value ? "Active" : "Inactive"}
@ -320,10 +337,12 @@ function TechniciansFieldArray({
control, control,
errors, errors,
tradeOptions, tradeOptions,
onRequestDeactivate,
}: { }: {
control: Control<VendorCompanyRosterFormValues>; control: Control<VendorCompanyRosterFormValues>;
errors: FieldErrors<VendorCompanyRosterFormValues>; errors: FieldErrors<VendorCompanyRosterFormValues>;
tradeOptions: string[]; tradeOptions: string[];
onRequestDeactivate?: (technician: RosterTechnicianValues) => void;
}) { }) {
const { fields, append, remove } = useFieldArray({ control, name: "technicians" }); const { fields, append, remove } = useFieldArray({ control, name: "technicians" });
@ -366,6 +385,7 @@ function TechniciansFieldArray({
onRemove={() => remove(index)} onRemove={() => remove(index)}
canRemove canRemove
tradeOptions={tradeOptions} tradeOptions={tradeOptions}
onRequestDeactivate={onRequestDeactivate}
/> />
)) ))
)} )}
@ -375,12 +395,17 @@ function TechniciansFieldArray({
} }
export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) { export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
const { control, errors, tradeOptions = [] } = props; const { control, errors, tradeOptions = [], onRequestDeactivate } = props;
return ( return (
<Stack spacing={3}> <Stack spacing={3}>
<CompanyFields {...props} /> <CompanyFields {...props} />
<Divider /> <Divider />
<TechniciansFieldArray control={control} errors={errors} tradeOptions={tradeOptions} /> <TechniciansFieldArray
control={control}
errors={errors}
tradeOptions={tradeOptions}
onRequestDeactivate={onRequestDeactivate}
/>
</Stack> </Stack>
); );
} }

View file

@ -1,17 +1,44 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material"; import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material";
import { VendorDeactivationDialog } from "./vendor-deactivation-dialog";
import { VendorPortalTokenPanel } from "./vendor-portal-token-panel"; import { VendorPortalTokenPanel } from "./vendor-portal-token-panel";
import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
import { VendorRosterFormFields } from "./vendor-roster-form-fields"; import { VendorRosterFormFields } from "./vendor-roster-form-fields";
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
import { useVendorDeactivation } from "./use-vendor-deactivation";
import { useVendorRosterForm } from "./use-vendor-roster-form"; import { useVendorRosterForm } from "./use-vendor-roster-form";
import type { RosterTechnicianValues } from "@/domain/vendors/schemas/vendor-roster-schema";
import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor";
interface VendorRosterPageProps { interface VendorRosterPageProps {
vendorId?: string; vendorId?: string;
companyId?: string; companyId?: string;
} }
function toDeactivationListItem(
roster: VendorCompanyRoster,
technician: RosterTechnicianValues,
): VendorListItem {
return {
id: technician.id ?? null,
companyId: roster.companyId,
companyName: roster.name,
contactName: technician.contactName,
email: technician.email,
phone: technician.phone,
companyPhone: roster.companyPhone,
googleMapsUrl: roster.googleMapsUrl,
notes: roster.notes,
totalJobs: 0,
city: roster.city,
state: roster.state,
tradeSpecialties: technician.tradeSpecialties,
isActive: technician.isActive,
preferredContact: technician.preferredContact ?? "Phone",
};
}
function PageShell({ function PageShell({
title, title,
subtitle, subtitle,
@ -43,6 +70,9 @@ function PageShell({
export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) { export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const isEdit = vendorId !== undefined || companyId !== undefined; const isEdit = vendorId !== undefined || companyId !== undefined;
const deactivation = useVendorDeactivation({
onSuccess: () => navigate("/vendors"),
});
const form = useVendorRosterForm({ const form = useVendorRosterForm({
mode: isEdit ? "update" : "create", mode: isEdit ? "update" : "create",
vendorId, vendorId,
@ -50,6 +80,11 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
onSuccess: () => navigate("/vendors"), onSuccess: () => navigate("/vendors"),
}); });
const handleRequestDeactivate = (technician: RosterTechnicianValues) => {
if (!form.roster || technician.id == null) return;
deactivation.open(toDeactivationListItem(form.roster, technician));
};
if (isEdit && form.isLoading) { if (isEdit && form.isLoading) {
return ( return (
<Box className="flex justify-center p-10"> <Box className="flex justify-center p-10">
@ -104,6 +139,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
control={form.control} control={form.control}
errors={form.errors} errors={form.errors}
tradeOptions={form.trades} tradeOptions={form.trades}
onRequestDeactivate={isEdit ? handleRequestDeactivate : undefined}
{...companySelectionProps} {...companySelectionProps}
/> />
@ -125,6 +161,17 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
</Stack> </Stack>
</Stack> </Stack>
</Box> </Box>
<VendorDeactivationDialog
target={deactivation.target}
isLoading={deactivation.isLoading}
isSaving={deactivation.isSaving}
error={deactivation.error}
impact={deactivation.impact}
impactError={deactivation.impactError}
onClose={deactivation.close}
onConfirm={deactivation.confirm}
/>
</PageShell> </PageShell>
); );
} }

View file

@ -14,6 +14,7 @@ import {
useUploadWorkOrderMedia, useUploadWorkOrderMedia,
} from "@/domain/work-orders/use-cases/use-work-order-mutations"; } from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits"; import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
import { useAuthContext } from "@/providers/auth-context"; import { useAuthContext } from "@/providers/auth-context";
import { useSlideOverEditState } from "./use-slide-over-edit-state"; import { useSlideOverEditState } from "./use-slide-over-edit-state";
import { useSlideOverMediaState } from "./use-slide-over-media-state"; import { useSlideOverMediaState } from "./use-slide-over-media-state";
@ -99,13 +100,22 @@ export function useWorkOrderSlideOver({
uploadCompletionMutation.mutate( uploadCompletionMutation.mutate(
{ file, workOrderVersion: version }, { file, workOrderVersion: version },
{ {
onSuccess: () => { onSuccess: async () => {
// Mirror CompDoc finalize: ensure completedDate then mark docStatus Yes. // Mirror CompDoc finalize: ensure completedDate then mark docStatus Yes.
const patch: WorkOrderTablePatch = { docStatus: "Yes" }; const patch: WorkOrderTablePatch = { docStatus: "Yes" };
if (!infoSource.completedDate) { if (!infoSource.completedDate) {
patch.completedDate = new Date().toISOString().slice(0, 10); patch.completedDate = todayIso();
}
try {
await new Promise<void>((resolve, reject) => {
onSave(row.id, patch, {
onSuccess: () => resolve(),
onError: (error) => reject(error),
});
});
} catch {
toast.error("Uploaded PDF but failed to update document status. Refresh and retry.");
} }
onSave(row.id, patch);
}, },
}, },
); );

View file

@ -41,6 +41,7 @@ export function WizardStepLocationSiteSelect({
siteCode: loc ? resolveSiteCode(loc) : "", siteCode: loc ? resolveSiteCode(loc) : "",
pocName: "", pocName: "",
pocPhone: "", pocPhone: "",
pocNotes: "",
}); });
}} }}
/> />

View file

@ -91,6 +91,11 @@ export function useSaveVendorCompanyRoster(): UseMutationResult<
? getSingleStatusOnlyChange(originalRoster, values) ? getSingleStatusOnlyChange(originalRoster, values)
: null; : null;
if (statusChange) { if (statusChange) {
if (statusChange.isActive === false) {
throw new Error(
"Use Deactivate to check open work orders before inactivating a technician.",
);
}
const baseRoster = originalRoster as VendorCompanyRoster; const baseRoster = originalRoster as VendorCompanyRoster;
await vendorsApi.update(statusChange.id, { isActive: statusChange.isActive }); await vendorsApi.update(statusChange.id, { isActive: statusChange.isActive });
return { return {

View file

@ -3,9 +3,16 @@ import type {
WorkOrderTypeApi, WorkOrderTypeApi,
} from "@/domain/work-orders/types/work-order-board"; } from "@/domain/work-orders/types/work-order-board";
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board"; import {
LIFECYCLE_STATUS_API,
WORK_ORDER_TYPE_API,
} from "@/domain/work-orders/types/work-order-board";
import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; import type {
WizardWOStatus,
WOType,
WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import { fmtDateRange } from "@/domain/work-orders/utils/wizard-date-utils"; import { fmtDateRange } from "@/domain/work-orders/utils/wizard-date-utils";
@ -98,6 +105,10 @@ export function mapWizardDraftToBoardCreatePayload(
siteCode, siteCode,
}; };
if (draft.status) {
payload.lifecycleStatus = LIFECYCLE_STATUS_API[draft.status as WizardWOStatus];
}
if (draft.woNumber.trim()) { if (draft.woNumber.trim()) {
payload.woNumber = draft.woNumber.trim(); payload.woNumber = draft.woNumber.trim();
} }

View file

@ -64,6 +64,8 @@ export const LIFECYCLE_STATUS_API_TO_FE: Record<number, WizardWOStatus | "Cancel
export interface WorkOrderBoardCreatePayload { export interface WorkOrderBoardCreatePayload {
workOrderType: WorkOrderTypeApi; workOrderType: WorkOrderTypeApi;
siteCode: string; siteCode: string;
/** Lifecycle status selected in the wizard; numeric board enum. */
lifecycleStatus?: LifecycleStatusApi;
woNumber?: string; woNumber?: string;
assignTo?: string; assignTo?: string;
dueDate?: string; dueDate?: string;

View file

@ -24,11 +24,16 @@ vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
useVendorFacets: () => ({ data: { companies: [], trades: [] } }), useVendorFacets: () => ({ data: { companies: [], trades: [] } }),
})); }));
const saveMutate = vi.fn();
vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () => { vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () => {
const actual = await vi.importActual< const actual = await vi.importActual<
typeof import("@/domain/vendors/use-cases/use-save-vendor-company-roster") typeof import("@/domain/vendors/use-cases/use-save-vendor-company-roster")
>("@/domain/vendors/use-cases/use-save-vendor-company-roster"); >("@/domain/vendors/use-cases/use-save-vendor-company-roster");
return { ...actual, useSaveVendorCompanyRoster: () => ({ mutate: vi.fn(), isPending: false }) }; return {
...actual,
useSaveVendorCompanyRoster: () => ({ mutate: saveMutate, isPending: false }),
};
}); });
import { useVendorRosterForm } from "@/app/(protected)/vendors/_components/use-vendor-roster-form"; import { useVendorRosterForm } from "@/app/(protected)/vendors/_components/use-vendor-roster-form";
@ -101,6 +106,7 @@ function deferRoster(): {
describe("useVendorRosterForm load-error handling", () => { describe("useVendorRosterForm load-error handling", () => {
beforeEach(() => { beforeEach(() => {
rosterGet.mockReset(); rosterGet.mockReset();
saveMutate.mockReset();
}); });
it("shows a safe retryable error when selecting an existing company fetch is rejected", async () => { it("shows a safe retryable error when selecting an existing company fetch is rejected", async () => {
@ -199,6 +205,7 @@ describe("useVendorRosterForm load-error handling", () => {
describe("useVendorRosterForm stale-selection handling", () => { describe("useVendorRosterForm stale-selection handling", () => {
beforeEach(() => { beforeEach(() => {
rosterGet.mockReset(); rosterGet.mockReset();
saveMutate.mockReset();
}); });
it("keeps the newest company roster when two lookups resolve out of order", async () => { it("keeps the newest company roster when two lookups resolve out of order", async () => {
@ -273,3 +280,97 @@ describe("useVendorRosterForm stale-selection handling", () => {
expect(result.current.name.field.value).toBe("Draft Vendor"); expect(result.current.name.field.value).toBe("Draft Vendor");
}); });
}); });
describe("useVendorRosterForm blank technician filtering", () => {
beforeEach(() => {
rosterGet.mockReset();
saveMutate.mockReset();
});
it("omits blank new technician rows from the save payload", () => {
const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), {
wrapper: makeWrapper(createClient()),
});
act(() => {
result.current.submit({
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
email: "dispatch@gateway.test",
address: "",
city: "",
state: "",
zip: "",
googleMapsUrl: "",
notes: "",
technicians: [
{
contactName: "Taylor",
phone: "(314) 555-0199",
email: "taylor@gateway.test",
tradeSpecialties: "Plumbing",
isActive: true,
},
{
contactName: "",
phone: "",
email: "",
tradeSpecialties: "",
isActive: true,
},
],
});
});
expect(saveMutate).toHaveBeenCalledTimes(1);
const payload = saveMutate.mock.calls[0]?.[0] as {
values: { technicians: Array<{ contactName: string }> };
};
expect(payload.values.technicians).toHaveLength(1);
expect(payload.values.technicians[0]?.contactName).toBe("Taylor");
});
it("keeps existing technicians with an id even when contact fields are blank", () => {
const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), {
wrapper: makeWrapper(createClient()),
});
act(() => {
result.current.submit({
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
email: "dispatch@gateway.test",
address: "",
city: "",
state: "",
zip: "",
googleMapsUrl: "",
notes: "",
technicians: [
{
id: 9,
contactName: "",
phone: "",
email: "",
tradeSpecialties: "",
isActive: true,
},
],
});
});
const payload = saveMutate.mock.calls[0]?.[0] as {
values: { technicians: Array<{ id?: number }> };
};
expect(payload.values.technicians).toEqual([
{
id: 9,
contactName: "",
phone: "",
email: "",
tradeSpecialties: "",
isActive: true,
},
]);
});
});

View file

@ -1,7 +1,32 @@
import { describe, expect, it } from "vitest"; import { createElement, type ReactNode } from "react";
import { getSingleStatusOnlyChange } from "@/domain/vendors/use-cases/use-save-vendor-company-roster"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
getSingleStatusOnlyChange,
useSaveVendorCompanyRoster,
} from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor"; import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor";
const updateVendor = vi.fn();
vi.mock("@/domain/vendors/api/vendors-api", () => ({
vendorsApi: {
update: (...args: unknown[]) => updateVendor(...args),
},
}));
vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({
vendorCompanyRosterApi: {
update: vi.fn(),
create: vi.fn(),
},
}));
vi.mock("react-toastify", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
const roster: VendorCompanyRoster = { const roster: VendorCompanyRoster = {
companyId: 10, companyId: 10,
rowVersion: "rv-1", rowVersion: "rv-1",
@ -69,3 +94,49 @@ describe("getSingleStatusOnlyChange", () => {
).toBeNull(); ).toBeNull();
}); });
}); });
describe("useSaveVendorCompanyRoster status-only deactivation gate", () => {
beforeEach(() => {
updateVendor.mockReset();
updateVendor.mockResolvedValue(undefined);
});
it("rejects sparse Active→Inactive updates and still allows reactivation", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrapper = ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client }, children);
const { result } = renderHook(() => useSaveVendorCompanyRoster(), { wrapper });
const activeRoster: VendorCompanyRoster = {
...roster,
technicians: [{ ...roster.technicians[0], isActive: true }],
};
await expect(
result.current.mutateAsync({
mode: "update",
values: {
...values,
technicians: [{ ...values.technicians[0], isActive: false }],
},
companyId: 10,
rowVersion: "rv-1",
originalRoster: activeRoster,
}),
).rejects.toThrow("Use Deactivate to check open work orders before inactivating a technician.");
expect(updateVendor).not.toHaveBeenCalled();
await result.current.mutateAsync({
mode: "update",
values: {
...values,
technicians: [{ ...values.technicians[0], isActive: true }],
},
companyId: 10,
rowVersion: "rv-1",
originalRoster: roster,
});
expect(updateVendor).toHaveBeenCalledWith(7, { isActive: true });
});
});

View file

@ -119,6 +119,7 @@ describe("workOrdersApi.createFromBoard", () => {
expect(apiPost).toHaveBeenCalledWith(API_PATHS.workOrder.boardCreate, { expect(apiPost).toHaveBeenCalledWith(API_PATHS.workOrder.boardCreate, {
workOrderType: 2, workOrderType: 2,
siteCode: "BK5", siteCode: "BK5",
lifecycleStatus: 1,
woNumber: "12345", woNumber: "12345",
}); });
}); });

View file

@ -55,9 +55,25 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
workOrderType: 2, workOrderType: 2,
siteCode: "BK5", siteCode: "BK5",
lifecycleStatus: 1,
}); });
}); });
it("maps wizard status to lifecycleStatus on create", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
type: "PM",
siteCode: "BK5",
status: "Scheduled",
});
expect(payload.lifecycleStatus).toBe(3);
});
it("maps full wizard payload with structured service and notes fields", () => { it("maps full wizard payload with structured service and notes fields", () => {
const payload = mapWizardDraftToBoardCreatePayload({ const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT, ...EMPTY_WIZARD_DRAFT,

View file

@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
fmtDateRange, fmtDateRange,
fmtShortDate, fmtShortDate,
pad2,
rangeDayCount, rangeDayCount,
todayIso,
} from "@/domain/work-orders/utils/wizard-date-utils"; } from "@/domain/work-orders/utils/wizard-date-utils";
describe("fmtShortDate", () => { describe("fmtShortDate", () => {
@ -29,3 +31,27 @@ describe("rangeDayCount", () => {
expect(rangeDayCount("2026-06-08", "2026-06-08")).toBe(1); expect(rangeDayCount("2026-06-08", "2026-06-08")).toBe(1);
}); });
}); });
describe("todayIso", () => {
afterEach(() => {
vi.useRealTimers();
});
it("uses local calendar day, not UTC ISO date, near midnight", () => {
// 02:30 UTC is still the previous local evening in Americas (UTC−N).
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-11T02:30:00.000Z"));
const now = new Date();
const expectedLocal = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
const utcSlice = now.toISOString().slice(0, 10);
expect(todayIso()).toBe(expectedLocal);
expect(utcSlice).toBe("2026-08-11");
if (now.getTimezoneOffset() > 0) {
expect(todayIso()).toBe("2026-08-10");
expect(todayIso()).not.toBe(utcSlice);
}
});
});