Merge remote-tracking branch 'origin/main' into feat/ab/sh-335-wo-site-dialog
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 198 KiB |
|
|
@ -6,6 +6,7 @@ import { ProtectedRoute } from "@/components/auth/protected-route";
|
|||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||
import { getSidebarWidth } from "@/config/menu";
|
||||
import { AppTopbar } from "@/components/layout/app-topbar";
|
||||
import { SlaAtRiskBanner } from "@/components/notifications/sla-at-risk-banner";
|
||||
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
|
||||
|
||||
export default function AdminShellLayout() {
|
||||
|
|
@ -40,6 +41,7 @@ export default function AdminShellLayout() {
|
|||
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
|
||||
>
|
||||
<AppTopbar onToggleNav={handleToggleNav} />
|
||||
<SlaAtRiskBanner />
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { Text } from "@/components/ui/text";
|
||||
import type { AuthUser } from "@/domain/auth/types/auth-user";
|
||||
|
||||
function ReadOnlyValue({ label, value }: { label: string; value: string | undefined }) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Text variant="label" tone="muted" className="text-[12px]!">
|
||||
{label}
|
||||
</Text>
|
||||
<Text variant="body" className="m-0 text-[14px]!">
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountSummaryCard({ user }: { user: AuthUser }) {
|
||||
return (
|
||||
<section
|
||||
aria-label="Account details"
|
||||
className="grid gap-4 rounded-lg border border-(--color-border) bg-(--color-bg-surface) p-5"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ReadOnlyValue label="Name" value={user.fullname} />
|
||||
<ReadOnlyValue label="Role" value={user.userRoles} />
|
||||
</div>
|
||||
<ReadOnlyValue label="Email" value={user.email} />
|
||||
<Text variant="caption" tone="muted" className="text-[11.5px]!">
|
||||
Contact your Admin to change your name, role, or email.
|
||||
</Text>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Box } from "@mui/material";
|
||||
import { KeyRound } from "lucide-react";
|
||||
|
||||
import { PasswordChecklist } from "@/components/auth/password-checklist";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FormField } from "@/components/ui/form-field";
|
||||
import { PasswordField } from "@/components/ui/password-field";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
CURRENT_PASSWORD_INCORRECT_MESSAGE,
|
||||
changePasswordSchema,
|
||||
type ChangePasswordFormValues,
|
||||
} from "@/domain/auth/schemas/change-password-schema";
|
||||
import { useChangePassword } from "@/domain/auth/use-cases/use-change-password";
|
||||
|
||||
const EMPTY_VALUES: ChangePasswordFormValues = {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
};
|
||||
|
||||
export function ChangePasswordSection() {
|
||||
const changePassword = useChangePassword();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<ChangePasswordFormValues>({
|
||||
resolver: zodResolver(changePasswordSchema),
|
||||
defaultValues: EMPTY_VALUES,
|
||||
mode: "onTouched",
|
||||
});
|
||||
|
||||
const values = useWatch({ control });
|
||||
const newPassword = values.newPassword ?? "";
|
||||
const canSubmit = changePasswordSchema.safeParse(values).success;
|
||||
const serverError = changePassword.error?.message;
|
||||
const showServerError =
|
||||
Boolean(serverError) && serverError !== CURRENT_PASSWORD_INCORRECT_MESSAGE;
|
||||
|
||||
const onSubmit = (form: ChangePasswordFormValues) => {
|
||||
changePassword.mutate(
|
||||
{
|
||||
Currentpassword: form.currentPassword,
|
||||
Newpassword: form.newPassword,
|
||||
Confirmpassword: form.confirmPassword,
|
||||
},
|
||||
{
|
||||
onSuccess: () => reset(EMPTY_VALUES),
|
||||
onError: (error) => {
|
||||
if (error.message === CURRENT_PASSWORD_INCORRECT_MESSAGE) {
|
||||
setError("currentPassword", { message: CURRENT_PASSWORD_INCORRECT_MESSAGE });
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="form"
|
||||
aria-labelledby="change-password-heading"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="grid gap-4 rounded-lg border border-(--color-border) bg-(--color-bg-surface) p-5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={15} aria-hidden="true" className="text-primary" />
|
||||
<Text id="change-password-heading" variant="heading" className="m-0 text-[15px]!">
|
||||
Change Password
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="Current password"
|
||||
htmlFor="account-current-password"
|
||||
required
|
||||
error={Boolean(errors.currentPassword)}
|
||||
helperText={errors.currentPassword?.message}
|
||||
>
|
||||
<PasswordField
|
||||
{...register("currentPassword")}
|
||||
id="account-current-password"
|
||||
autoComplete="current-password"
|
||||
error={Boolean(errors.currentPassword)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="New password" htmlFor="account-new-password" required>
|
||||
<PasswordField
|
||||
{...register("newPassword")}
|
||||
id="account-new-password"
|
||||
autoComplete="new-password"
|
||||
slotProps={{ htmlInput: { "aria-describedby": "account-password-rules" } }}
|
||||
/>
|
||||
<PasswordChecklist id="account-password-rules" password={newPassword} />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Confirm new password"
|
||||
htmlFor="account-confirm-password"
|
||||
required
|
||||
error={Boolean(errors.confirmPassword)}
|
||||
helperText={errors.confirmPassword?.message}
|
||||
>
|
||||
<PasswordField
|
||||
{...register("confirmPassword")}
|
||||
id="account-confirm-password"
|
||||
autoComplete="new-password"
|
||||
error={Boolean(errors.confirmPassword)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{showServerError && <Text variant="error">{serverError}</Text>}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
loading={changePassword.isPending}
|
||||
loadingText="Updating…"
|
||||
>
|
||||
Update password
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
24
src/app/(protected)/account/index.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Box } from "@mui/material";
|
||||
|
||||
import { AccountSummaryCard } from "@/app/(protected)/account/_components/account-summary-card";
|
||||
import { ChangePasswordSection } from "@/app/(protected)/account/_components/change-password-section";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
||||
export default function MyAccountPage() {
|
||||
const { user } = useAuthContext();
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-2 p-2.5">
|
||||
<PageHeader
|
||||
surface
|
||||
title="My Account"
|
||||
subtitle="Review your account and manage your password"
|
||||
/>
|
||||
<Box className="grid max-w-md gap-5 px-5 pb-5">
|
||||
{user != null && <AccountSummaryCard user={user} />}
|
||||
<ChangePasswordSection />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { Controller, useWatch, type UseFormReturn } from "react-hook-form";
|
||||
import { Controller, useController, useWatch, type UseFormReturn } from "react-hook-form";
|
||||
import { Stack, TextField } from "@mui/material";
|
||||
import { AddressAutocompleteField } from "@/components/common/address-autocomplete-field";
|
||||
import { normalizeStateCode } from "@/data/areas";
|
||||
import type { AccountListItem } from "@/domain/accounts/types/account";
|
||||
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
||||
import { ClientSelect } from "./client-select";
|
||||
|
|
@ -26,6 +28,9 @@ export function SiteFormFields({
|
|||
formState: { errors },
|
||||
} = form;
|
||||
const [address, city, state] = useWatch({ control, name: ["address", "city", "state"] });
|
||||
const { field: addressField } = useController({ control, name: "address" });
|
||||
const { field: cityField } = useController({ control, name: "city" });
|
||||
const { field: stateField } = useController({ control, name: "state" });
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
|
|
@ -50,18 +55,27 @@ export function SiteFormFields({
|
|||
)}
|
||||
/>
|
||||
<ClientSelect control={control} error={errors.accountId} initialClient={initialClient} />
|
||||
<TextField
|
||||
{...register("address")}
|
||||
<AddressAutocompleteField
|
||||
value={addressField.value}
|
||||
onInputChange={addressField.onChange}
|
||||
onSelect={(parts) => {
|
||||
// One pick fills all three, each through its own controller so the
|
||||
// form marks them dirty and revalidates (edit mode's Save depends on it).
|
||||
addressField.onChange(parts.street);
|
||||
cityField.onChange(parts.city);
|
||||
stateField.onChange(normalizeStateCode(parts.state) ?? "");
|
||||
}}
|
||||
label="Street Address"
|
||||
placeholder="Street address"
|
||||
required
|
||||
requiredMarker
|
||||
error={Boolean(errors.address)}
|
||||
helperText={errors.address?.message}
|
||||
fullWidth
|
||||
helperText={
|
||||
errors.address?.message ?? "Pick a suggestion to auto-fill City/State, or type your own."
|
||||
}
|
||||
/>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
{...register("city")}
|
||||
{...cityField}
|
||||
label="City"
|
||||
required
|
||||
error={Boolean(errors.city)}
|
||||
|
|
|
|||
|
|
@ -109,7 +109,11 @@ export function AdvancedSearchFilterSections({
|
|||
/>
|
||||
</AdvSection>
|
||||
|
||||
<AdvSection icon={CheckCircle2} label="Status" count={draft.statuses.size}>
|
||||
<AdvSection
|
||||
icon={CheckCircle2}
|
||||
label="Status"
|
||||
count={draft.statuses.size + (draft.pastDue ? 1 : 0)}
|
||||
>
|
||||
{selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ALL_WIZARD_STATUSES.map((status) => (
|
||||
|
|
@ -122,6 +126,14 @@ export function AdvancedSearchFilterSections({
|
|||
onClick={() => toggleIn("statuses", status)}
|
||||
/>
|
||||
))}
|
||||
<AdvStatusPill
|
||||
label="Past Due"
|
||||
dotColor="var(--status-pastdue-text)"
|
||||
selected={draft.pastDue}
|
||||
selectedBg="var(--status-pastdue-bg)"
|
||||
selectedText="var(--status-pastdue-text)"
|
||||
onClick={() => setDraft((prev) => ({ ...prev, pastDue: !prev.pastDue }))}
|
||||
/>
|
||||
</div>
|
||||
</AdvSection>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
|||
import type { WorkOrderBoardQueryParams } from "@/domain/work-orders/types/work-order-board";
|
||||
import {
|
||||
clearAdvancedFilterGroup,
|
||||
cloneAdvancedFilters,
|
||||
countAdvancedFilters,
|
||||
getInitialAdvancedFilters,
|
||||
resolveAppliedAdvancedFilters,
|
||||
} from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||
import { toBoardQueryParams } from "@/domain/work-orders/utils/board-query-params";
|
||||
import { asDayPageSize, DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination";
|
||||
|
|
@ -90,7 +90,7 @@ export function useWorkOrdersListFilters() {
|
|||
};
|
||||
|
||||
const applyAdvancedFilters = (filters: WorkOrderAdvancedFilters) => {
|
||||
setAdvApplied(cloneAdvancedFilters(filters));
|
||||
setAdvApplied(resolveAppliedAdvancedFilters(filters));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
|
|
|
|||
63
src/components/auth/password-checklist.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useState } from "react";
|
||||
import { Circle, CircleCheck } from "lucide-react";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { evaluatePasswordRules, type PasswordRuleResult } from "@/domain/auth/password-policy";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type PasswordChecklistProps = {
|
||||
password: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function describeChanges(previous: PasswordRuleResult[], next: PasswordRuleResult[]): string {
|
||||
return next
|
||||
.filter((rule, index) => rule.met !== previous[index]?.met)
|
||||
.map((rule) => `${rule.label}: ${rule.met ? "requirement met" : "requirement not met"}`)
|
||||
.join(". ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Live, per-rule feedback for the shared password policy. A polite status region
|
||||
* announces only the rules whose state changed, so screen-reader users hear
|
||||
* progress while typing without the whole list being re-read on each keystroke.
|
||||
*/
|
||||
export function PasswordChecklist({ password, id, className }: PasswordChecklistProps) {
|
||||
const results = evaluatePasswordRules(password);
|
||||
const signature = results.map((rule) => (rule.met ? "1" : "0")).join("");
|
||||
const [previous, setPrevious] = useState({ signature, results });
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
|
||||
if (previous.signature !== signature) {
|
||||
setPrevious({ signature, results });
|
||||
setAnnouncement(describeChanges(previous.results, results));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul
|
||||
id={id}
|
||||
aria-label="Password requirements"
|
||||
className={cn("m-0 mt-2 grid list-none gap-1 p-0", className)}
|
||||
>
|
||||
{results.map(({ id: ruleId, label, met }) => (
|
||||
<li key={ruleId} data-rule={ruleId} data-met={met} className="flex items-center gap-1.5">
|
||||
{met ? (
|
||||
<CircleCheck size={13} aria-hidden="true" className="text-(--color-success)" />
|
||||
) : (
|
||||
<Circle size={13} aria-hidden="true" className="text-(--color-text-muted)" />
|
||||
)}
|
||||
<Text variant="caption" tone={met ? "success" : "muted"}>
|
||||
{label}
|
||||
</Text>
|
||||
<span className="sr-only">{met ? "requirement met" : "requirement not met"}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Text variant="feedback" className="sr-only">
|
||||
{announcement}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,6 +13,11 @@ type AddressAutocompleteFieldProps = {
|
|||
label?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
/**
|
||||
* Show MUI's required asterisk on the label. Sites marks required fields that
|
||||
* way; Vendors spells "(required)" in the label and asserts no asterisk.
|
||||
*/
|
||||
requiredMarker?: boolean;
|
||||
error?: boolean;
|
||||
helperText?: string;
|
||||
disabled?: boolean;
|
||||
|
|
@ -35,6 +40,7 @@ export function AddressAutocompleteField({
|
|||
label = "Street Address",
|
||||
placeholder = "Start typing the street address…",
|
||||
required = false,
|
||||
requiredMarker = false,
|
||||
error = false,
|
||||
helperText,
|
||||
disabled = false,
|
||||
|
|
@ -74,9 +80,9 @@ export function AddressAutocompleteField({
|
|||
error={error}
|
||||
helperText={helperText}
|
||||
fullWidth
|
||||
// Required is set on the input itself, never via MUI's `required`
|
||||
// prop: this codebase spells the requirement in the label text and
|
||||
// asserts that no generated asterisk renders anywhere.
|
||||
required={requiredMarker}
|
||||
// Required is set on the input itself; MUI's `required` prop only
|
||||
// adds the label asterisk, which Vendors must not render.
|
||||
slotProps={{
|
||||
...params.slotProps,
|
||||
htmlInput: { ...params.slotProps?.htmlInput, required },
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { Box, Drawer } from "@mui/material";
|
||||
import { CircleUserRound } from "lucide-react";
|
||||
|
||||
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
|
||||
import { useNavGroups } from "@/components/layout/use-nav-groups";
|
||||
import { getActiveNavPath, getSidebarWidth, type NavItemDef } from "@/config/menu";
|
||||
|
||||
const ACCOUNT_PATH = "/account";
|
||||
|
||||
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
|
||||
if (isActive) {
|
||||
return {
|
||||
|
|
@ -222,6 +225,21 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }:
|
|||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="nav"
|
||||
aria-label="Account navigation"
|
||||
sx={{ borderTop: "1px solid var(--color-sidebar-border)", p: "8px" }}
|
||||
>
|
||||
<NavItem
|
||||
label="My Account"
|
||||
icon={CircleUserRound}
|
||||
path={ACCOUNT_PATH}
|
||||
collapsed={collapsed}
|
||||
active={pathname === ACCOUNT_PATH}
|
||||
onNavigate={mobile ? onClose : undefined}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Drawer>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { isDismissable } from "@/domain/notifications/utils/visible-notification-sections";
|
||||
import type { NotificationCenter } from "./use-notification-center";
|
||||
|
||||
type NotificationFeedActionsProps = {
|
||||
|
|
@ -6,7 +7,8 @@ type NotificationFeedActionsProps = {
|
|||
|
||||
export function NotificationFeedActions({ center }: NotificationFeedActionsProps) {
|
||||
const hasUnread = center.unreadIds.size > 0;
|
||||
const hasItems = center.sections.length > 0;
|
||||
// Rows that need acknowledging are never cleared, so they alone do not enable "Clear all".
|
||||
const hasItems = center.sections.some((section) => section.items.some(isDismissable));
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -79,6 +79,11 @@ export function NotificationFeedList({ center }: NotificationFeedListProps) {
|
|||
unread={center.unreadIds.has(item.id)}
|
||||
onOpen={center.open}
|
||||
onDismiss={center.dismiss}
|
||||
onAcknowledge={center.acknowledge}
|
||||
acknowledging={
|
||||
item.target.workOrderId != null &&
|
||||
item.target.workOrderId === center.acknowledgingWorkOrderId
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -53,8 +53,37 @@ type NotificationRowProps = {
|
|||
unread: boolean;
|
||||
onOpen: (item: NotificationItem, url?: string) => void;
|
||||
onDismiss: (item: NotificationItem) => void;
|
||||
onAcknowledge?: (item: NotificationItem) => void;
|
||||
acknowledging?: boolean;
|
||||
};
|
||||
|
||||
/** A missed response deadline cannot be dismissed; acknowledging it is the only way out of the feed. */
|
||||
function AcknowledgeButton({
|
||||
item,
|
||||
onAcknowledge,
|
||||
acknowledging = false,
|
||||
}: Pick<NotificationRowProps, "item" | "onAcknowledge" | "acknowledging">) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAcknowledge?.(item)}
|
||||
disabled={acknowledging}
|
||||
title="Acknowledge"
|
||||
aria-label="Acknowledge SLA breach"
|
||||
className="shrink-0 cursor-pointer rounded-md border transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
padding: "2px 8px",
|
||||
color: "var(--destructive)",
|
||||
borderColor: "var(--destructive)",
|
||||
}}
|
||||
>
|
||||
{acknowledging ? "Acknowledging…" : "Acknowledge"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A vendor conflict spans several work orders, so each one stays reachable from the row. */
|
||||
function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "onOpen">) {
|
||||
if (item.reason !== "vendor-conflict" || item.workOrders.length === 0) {
|
||||
|
|
@ -84,7 +113,14 @@ function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "o
|
|||
);
|
||||
}
|
||||
|
||||
export function NotificationRow({ item, unread, onOpen, onDismiss }: NotificationRowProps) {
|
||||
export function NotificationRow({
|
||||
item,
|
||||
unread,
|
||||
onOpen,
|
||||
onDismiss,
|
||||
onAcknowledge,
|
||||
acknowledging,
|
||||
}: NotificationRowProps) {
|
||||
return (
|
||||
<li
|
||||
className="group flex items-start gap-2.5 transition-colors hover:bg-[var(--color-bg-muted)]"
|
||||
|
|
@ -136,6 +172,13 @@ export function NotificationRow({ item, unread, onOpen, onDismiss }: Notificatio
|
|||
<X size={13} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
{item.rowType === "acknowledge" && (
|
||||
<AcknowledgeButton
|
||||
item={item}
|
||||
onAcknowledge={onAcknowledge}
|
||||
acknowledging={acknowledging}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
90
src/components/notifications/sla-at-risk-banner.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { useEffect, useMemo } from "react";
|
||||
import { AlertTriangle, ChevronRight, X } from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "react-toastify";
|
||||
import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed";
|
||||
import { useNotificationSession } from "@/domain/notifications/session/notification-session-context";
|
||||
import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
|
||||
import { slaBannerText, slaHalfwayToastText } from "@/domain/notifications/utils/sla-alert-copy";
|
||||
import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter";
|
||||
|
||||
const NO_WORK_ORDERS: SlaAtRiskWorkOrder[] = [];
|
||||
|
||||
/** Toasts each at-risk work order once per session, the first time the feed reports it. */
|
||||
function useSlaHalfwayToasts(atRisk: SlaAtRiskWorkOrder[]) {
|
||||
const { claimSlaToasts } = useNotificationSession();
|
||||
|
||||
useEffect(() => {
|
||||
const claimed = new Set(claimSlaToasts(atRisk.map((workOrder) => workOrder.id)));
|
||||
atRisk
|
||||
.filter((workOrder) => claimed.has(workOrder.id))
|
||||
.forEach((workOrder) =>
|
||||
toast.warn(slaHalfwayToastText(workOrder), { toastId: `sla-at-risk-${workOrder.id}` }),
|
||||
);
|
||||
}, [atRisk, claimSlaToasts]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One line at the top of every page while any of the user's Reactive/Emergency work orders is past
|
||||
* half of its response window. Clicking it opens Work Orders on exactly those work orders; dismissing
|
||||
* hides them for this session, and a work order that becomes at risk later brings the banner back.
|
||||
*/
|
||||
export function SlaAtRiskBanner() {
|
||||
const navigate = useNavigate();
|
||||
const { data } = useNotificationFeed();
|
||||
const { slaBannerDismissedIds, dismissSlaBanner } = useNotificationSession();
|
||||
const atRisk = data?.slaAtRisk ?? NO_WORK_ORDERS;
|
||||
useSlaHalfwayToasts(atRisk);
|
||||
|
||||
const shown = useMemo(
|
||||
() => atRisk.filter((workOrder) => !slaBannerDismissedIds.has(workOrder.id)),
|
||||
[atRisk, slaBannerDismissedIds],
|
||||
);
|
||||
if (shown.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ids = shown.map((workOrder) => workOrder.id);
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label="Work orders at risk of missing their response deadline"
|
||||
className="flex shrink-0 items-center"
|
||||
style={{
|
||||
gap: 10,
|
||||
padding: "6px 16px",
|
||||
background: "var(--destructive)",
|
||||
color: "var(--destructive-foreground)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(buildWorkOrderIdsHref(ids))}
|
||||
title="View in Work Orders"
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center text-left"
|
||||
style={{ color: "inherit" }}
|
||||
>
|
||||
<span
|
||||
className="inline-flex min-w-0 items-center transition-colors hover:bg-white/20"
|
||||
style={{ gap: 8, padding: "3px 8px", marginLeft: -8, borderRadius: 4 }}
|
||||
>
|
||||
<AlertTriangle size={14} className="shrink-0" aria-hidden />
|
||||
<span className="truncate" style={{ fontSize: 12.5, fontWeight: 600 }}>
|
||||
{slaBannerText(shown)}
|
||||
</span>
|
||||
<ChevronRight size={14} className="shrink-0" aria-hidden />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismissSlaBanner(ids)}
|
||||
title="Dismiss"
|
||||
aria-label="Dismiss SLA alert"
|
||||
className="flex shrink-0 cursor-pointer items-center justify-center rounded transition-colors hover:bg-white/20"
|
||||
style={{ width: 22, height: 22, color: "inherit" }}
|
||||
>
|
||||
<X size={14} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { useMemo } from "react";
|
|||
import { useNavigate } from "react-router";
|
||||
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
|
||||
import { useNotificationSession } from "@/domain/notifications/session/notification-session-context";
|
||||
import { useAcknowledgeSlaBreach } from "@/domain/notifications/use-cases/use-acknowledge-sla-breach";
|
||||
import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
|
||||
import { notificationTargetUrl } from "@/domain/notifications/utils/notification-target-url";
|
||||
import {
|
||||
|
|
@ -26,6 +27,13 @@ export function useNotificationCenter(onNavigate?: () => void) {
|
|||
[items, readIds],
|
||||
);
|
||||
|
||||
const acknowledgeBreach = useAcknowledgeSlaBreach();
|
||||
const acknowledge = (item: NotificationItem) => {
|
||||
if (item.rowType !== "acknowledge" || item.target.workOrderId == null) return;
|
||||
acknowledgeBreach.mutate(item.target.workOrderId);
|
||||
};
|
||||
const acknowledgingWorkOrderId = acknowledgeBreach.isPending ? acknowledgeBreach.variables : null;
|
||||
|
||||
const open = (item: NotificationItem, url = notificationTargetUrl(item, user?.id)) => {
|
||||
markRead([item.id]);
|
||||
if (url == null) return;
|
||||
|
|
@ -41,6 +49,8 @@ export function useNotificationCenter(onNavigate?: () => void) {
|
|||
error: feed.error,
|
||||
open,
|
||||
dismiss: (item: NotificationItem) => dismiss([item.id]),
|
||||
acknowledge,
|
||||
acknowledgingWorkOrderId,
|
||||
dismissAll: () => dismiss(items.filter(isDismissable).map((item) => item.id)),
|
||||
markAllRead: () => markRead(items.map((item) => item.id)),
|
||||
};
|
||||
|
|
|
|||
53
src/domain/auth/password-policy.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* The single password rule for every surface that sets a password. It mirrors the
|
||||
* server's Identity options: the character classes are ASCII, matching how the
|
||||
* server classifies uppercase letters, digits and non-alphanumeric characters.
|
||||
*/
|
||||
export const PASSWORD_MIN_LENGTH = 6;
|
||||
|
||||
export type PasswordRuleId = "length" | "uppercase" | "number" | "special";
|
||||
|
||||
export type PasswordRule = {
|
||||
id: PasswordRuleId;
|
||||
label: string;
|
||||
test: (password: string) => boolean;
|
||||
};
|
||||
|
||||
export type PasswordRuleResult = {
|
||||
id: PasswordRuleId;
|
||||
label: string;
|
||||
met: boolean;
|
||||
};
|
||||
|
||||
export const PASSWORD_RULES: readonly PasswordRule[] = [
|
||||
{
|
||||
id: "length",
|
||||
label: `At least ${PASSWORD_MIN_LENGTH} characters`,
|
||||
test: (password) => password.length >= PASSWORD_MIN_LENGTH,
|
||||
},
|
||||
{ id: "uppercase", label: "One uppercase letter", test: (password) => /[A-Z]/.test(password) },
|
||||
{ id: "number", label: "One number", test: (password) => /[0-9]/.test(password) },
|
||||
{
|
||||
id: "special",
|
||||
label: "One special character",
|
||||
test: (password) => /[^A-Za-z0-9]/.test(password),
|
||||
},
|
||||
];
|
||||
|
||||
export const PASSWORD_REQUIREMENTS_MESSAGE = "Password must meet every requirement below";
|
||||
|
||||
export const PASSWORDS_DO_NOT_MATCH_MESSAGE = "Passwords don't match";
|
||||
|
||||
export function evaluatePasswordRules(password: string): PasswordRuleResult[] {
|
||||
return PASSWORD_RULES.map(({ id, label, test }) => ({ id, label, met: test(password) }));
|
||||
}
|
||||
|
||||
export function meetsPasswordPolicy(password: string): boolean {
|
||||
return PASSWORD_RULES.every((rule) => rule.test(password));
|
||||
}
|
||||
|
||||
export const passwordSchema = z.string().refine(meetsPasswordPolicy, {
|
||||
message: PASSWORD_REQUIREMENTS_MESSAGE,
|
||||
});
|
||||
18
src/domain/auth/schemas/change-password-schema.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { PASSWORDS_DO_NOT_MATCH_MESSAGE, passwordSchema } from "@/domain/auth/password-policy";
|
||||
|
||||
export const CURRENT_PASSWORD_INCORRECT_MESSAGE = "Current password is incorrect";
|
||||
|
||||
export const changePasswordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: passwordSchema,
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((values) => values.newPassword === values.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: PASSWORDS_DO_NOT_MATCH_MESSAGE,
|
||||
});
|
||||
|
||||
export type ChangePasswordFormValues = z.infer<typeof changePasswordSchema>;
|
||||
|
|
@ -3,14 +3,13 @@ import { toast } from "react-toastify";
|
|||
import * as authApi from "@/domain/auth/api/auth-api";
|
||||
import type { ChangePasswordPayload } from "@/domain/auth/types/auth-user";
|
||||
|
||||
/** Errors are rendered inline by the form that initiated the change. */
|
||||
export function useChangePassword(): UseMutationResult<unknown, Error, ChangePasswordPayload> {
|
||||
return useMutation({
|
||||
mutationFn: (data: ChangePasswordPayload) => authApi.changePassword(data),
|
||||
meta: { suppressErrorToast: true },
|
||||
onSuccess: () => {
|
||||
toast.success("Password changed successfully!");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to change password");
|
||||
toast.success("Password updated");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { apiGet } from "@/api/api";
|
||||
import { apiGet, apiPostNoContent } from "@/api/api";
|
||||
import {
|
||||
notificationFeedSchema,
|
||||
type NotificationFeed,
|
||||
|
|
@ -7,4 +7,7 @@ import {
|
|||
export const notificationsApi = {
|
||||
feed: async (): Promise<NotificationFeed> =>
|
||||
notificationFeedSchema.parse(await apiGet<unknown>("notifications")),
|
||||
/** Records who acknowledged the missed response deadline in the work order's audit history. */
|
||||
acknowledgeSlaBreach: (workOrderId: number): Promise<void> =>
|
||||
apiPostNoContent(`notifications/sla/${workOrderId}/acknowledge`),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ export type NotificationSession = {
|
|||
readIds: ReadonlySet<string>;
|
||||
dismiss: (ids: string[]) => void;
|
||||
markRead: (ids: string[]) => void;
|
||||
/** At-risk work orders hidden from the SLA banner for this session. */
|
||||
slaBannerDismissedIds: ReadonlySet<number>;
|
||||
dismissSlaBanner: (workOrderIds: number[]) => void;
|
||||
/** Returns the ids not toasted yet this session and marks them toasted, so each toast fires once. */
|
||||
claimSlaToasts: (workOrderIds: number[]) => number[];
|
||||
};
|
||||
|
||||
export const NotificationSessionContext = createContext<NotificationSession | null>(null);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { NotificationSessionContext } from "./notification-session-context";
|
||||
|
||||
function addAll(current: ReadonlySet<string>, ids: string[]): ReadonlySet<string> {
|
||||
function addAll<T>(current: ReadonlySet<T>, ids: T[]): ReadonlySet<T> {
|
||||
if (ids.every((id) => current.has(id))) {
|
||||
return current;
|
||||
}
|
||||
|
|
@ -17,6 +17,11 @@ function addAll(current: ReadonlySet<string>, ids: string[]): ReadonlySet<string
|
|||
export function NotificationSessionProvider({ children }: { children: ReactNode }) {
|
||||
const [dismissedIds, setDismissedIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [readIds, setReadIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [slaBannerDismissedIds, setSlaBannerDismissedIds] = useState<ReadonlySet<number>>(
|
||||
() => new Set(),
|
||||
);
|
||||
// A ref, not state: claiming must be synchronous so a re-run effect can never toast twice.
|
||||
const toastedSlaIds = useRef(new Set<number>());
|
||||
|
||||
const dismiss = useCallback((ids: string[]) => {
|
||||
setDismissedIds((current) => addAll(current, ids));
|
||||
|
|
@ -24,10 +29,34 @@ export function NotificationSessionProvider({ children }: { children: ReactNode
|
|||
const markRead = useCallback((ids: string[]) => {
|
||||
setReadIds((current) => addAll(current, ids));
|
||||
}, []);
|
||||
const dismissSlaBanner = useCallback((workOrderIds: number[]) => {
|
||||
setSlaBannerDismissedIds((current) => addAll(current, workOrderIds));
|
||||
}, []);
|
||||
const claimSlaToasts = useCallback((workOrderIds: number[]) => {
|
||||
const claimed = workOrderIds.filter((id) => !toastedSlaIds.current.has(id));
|
||||
claimed.forEach((id) => toastedSlaIds.current.add(id));
|
||||
return claimed;
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ dismissedIds, readIds, dismiss, markRead }),
|
||||
[dismissedIds, readIds, dismiss, markRead],
|
||||
() => ({
|
||||
dismissedIds,
|
||||
readIds,
|
||||
dismiss,
|
||||
markRead,
|
||||
slaBannerDismissedIds,
|
||||
dismissSlaBanner,
|
||||
claimSlaToasts,
|
||||
}),
|
||||
[
|
||||
dismissedIds,
|
||||
readIds,
|
||||
dismiss,
|
||||
markRead,
|
||||
slaBannerDismissedIds,
|
||||
dismissSlaBanner,
|
||||
claimSlaToasts,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -50,9 +50,21 @@ const sectionSchema = z.object({
|
|||
items: z.array(itemSchema),
|
||||
});
|
||||
|
||||
/** A work order past half of its SEV response window, with the server-computed clock. */
|
||||
const slaAtRiskSchema = z.object({
|
||||
id: z.number(),
|
||||
number: z.string().nullish(),
|
||||
severity: z.number(),
|
||||
startedAt: z.string(),
|
||||
deadlineAt: z.string(),
|
||||
percentElapsed: z.number(),
|
||||
});
|
||||
|
||||
export const notificationFeedSchema = z.object({
|
||||
generatedAt: z.string(),
|
||||
sections: z.array(sectionSchema),
|
||||
// Defaulted so a feed from a server without SLA alerts still parses.
|
||||
slaAtRisk: z.array(slaAtRiskSchema).default([]),
|
||||
});
|
||||
|
||||
export type NotificationSeverity = z.infer<typeof severitySchema>;
|
||||
|
|
@ -60,3 +72,4 @@ export type NotificationTarget = z.infer<typeof targetSchema>;
|
|||
export type NotificationItem = z.infer<typeof itemSchema>;
|
||||
export type NotificationSection = z.infer<typeof sectionSchema>;
|
||||
export type NotificationFeed = z.infer<typeof notificationFeedSchema>;
|
||||
export type SlaAtRiskWorkOrder = z.infer<typeof slaAtRiskSchema>;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { notificationsApi } from "@/domain/notifications/api/notifications-api";
|
||||
import type {
|
||||
NotificationFeed,
|
||||
NotificationItem,
|
||||
} from "@/domain/notifications/types/notification-feed";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
function isBreachRowFor(workOrderId: number) {
|
||||
return (item: NotificationItem) =>
|
||||
item.reason === "sla" &&
|
||||
item.rowType === "acknowledge" &&
|
||||
item.target.workOrderId === workOrderId;
|
||||
}
|
||||
|
||||
/** The feed without the acknowledged work order's breach row, until the next poll confirms it. */
|
||||
export function withoutSlaBreach(feed: NotificationFeed, workOrderId: number): NotificationFeed {
|
||||
const matches = isBreachRowFor(workOrderId);
|
||||
return {
|
||||
...feed,
|
||||
sections: feed.sections.map((section) => {
|
||||
const removed = section.items.filter(matches);
|
||||
if (removed.length === 0) return section;
|
||||
return {
|
||||
...section,
|
||||
items: section.items.filter((item) => !matches(item)),
|
||||
count: Math.max(section.count - removed.reduce((total, item) => total + item.count, 0), 0),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function useAcknowledgeSlaBreach() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (workOrderId: number) => notificationsApi.acknowledgeSlaBreach(workOrderId),
|
||||
onSuccess: (_result, workOrderId) => {
|
||||
queryClient.setQueryData<NotificationFeed>(queryKeys.notifications.feed(), (feed) =>
|
||||
feed ? withoutSlaBreach(feed, workOrderId) : feed,
|
||||
);
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }),
|
||||
});
|
||||
}
|
||||
20
src/domain/notifications/utils/sla-alert-copy.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed";
|
||||
|
||||
function workOrderLabel(workOrder: Pick<SlaAtRiskWorkOrder, "id" | "number">): string {
|
||||
return workOrder.number != null && workOrder.number !== ""
|
||||
? `WO #${workOrder.number}`
|
||||
: `WO ${workOrder.id}`;
|
||||
}
|
||||
|
||||
/** One line whatever the count, as the top-of-app banner shows it. */
|
||||
export function slaBannerText(workOrders: Pick<SlaAtRiskWorkOrder, "id" | "number">[]): string {
|
||||
if (workOrders.length === 1) {
|
||||
return `${workOrderLabel(workOrders[0])} is at risk of missing its response deadline`;
|
||||
}
|
||||
return `${workOrders.length} work orders are at risk of missing their response deadline`;
|
||||
}
|
||||
|
||||
/** The one-time toast when a work order is first seen past half of its response window. */
|
||||
export function slaHalfwayToastText(workOrder: Pick<SlaAtRiskWorkOrder, "id" | "number">): string {
|
||||
return `${workOrderLabel(workOrder)} is at 50% of its SLA response window`;
|
||||
}
|
||||
|
|
@ -20,10 +20,13 @@ const SERVICE_WORK_ORDER_TYPE_API = {
|
|||
Reactive: 6,
|
||||
} as const;
|
||||
|
||||
const TYPE_TO_API: Record<Exclude<WOType, "Overdue">, number> = {
|
||||
// The registry is keyed on PM, Reactive and Emergency; Overdue is PM work the
|
||||
// client never confirmed, so it lists the PM catalog.
|
||||
const TYPE_TO_API: Record<WOType, number> = {
|
||||
PM: WORK_ORDER_TYPE_API.PM,
|
||||
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
||||
Emergency: WORK_ORDER_TYPE_API.Emergency,
|
||||
Overdue: WORK_ORDER_TYPE_API.PM,
|
||||
};
|
||||
|
||||
function toApiInput(input: ServiceInput) {
|
||||
|
|
@ -51,7 +54,6 @@ export const servicesApi = {
|
|||
},
|
||||
|
||||
getActiveForWorkOrderType: async (workOrderType: WOType): Promise<Service[]> => {
|
||||
if (workOrderType === "Overdue") return [];
|
||||
const data = await apiGet<unknown>(API_PATHS.services.list, {
|
||||
searchParams: { isActive: true, workOrderType: TYPE_TO_API[workOrderType] },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ export function useServicesForWorkOrderType(
|
|||
return useQuery({
|
||||
queryKey: queryKeys.services.byWorkOrderType(effectiveType),
|
||||
queryFn: () => servicesApi.getActiveForWorkOrderType(effectiveType),
|
||||
enabled: effectiveType !== "Overdue",
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
|
||||
import { typeNeedsSeverity } from "@/domain/work-orders/types/work-order-severity";
|
||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
|
||||
|
||||
|
|
@ -64,7 +65,7 @@ export function assignWizardServicePocFields(
|
|||
payload.vendorNotes = vendorNotes;
|
||||
}
|
||||
|
||||
if (draft.severity != null) {
|
||||
if (draft.severity != null && typeNeedsSeverity(draft.type)) {
|
||||
payload.severity = draft.severity;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const WO_TYPE_TO_API: Record<WOType, WorkOrderTypeApi> = {
|
|||
|
||||
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
||||
|
||||
Overdue: WORK_ORDER_TYPE_API.Other,
|
||||
Overdue: WORK_ORDER_TYPE_API.Overdue,
|
||||
};
|
||||
|
||||
/** Leading token of a location label, e.g. `BK5` from `BK5 · Dallas` or `CHI-1042` from `CHI-1042 — Chicago`. */
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ export interface WorkOrderAdvancedFilters {
|
|||
dispatchers: Set<string>;
|
||||
regions: Set<string>;
|
||||
statuses: Set<WizardWOStatus>;
|
||||
/** Past Due overlay (any type); narrows the other filters, independent of the Overdue type. */
|
||||
pastDue: boolean;
|
||||
rescheduled: boolean;
|
||||
carriedOver: boolean;
|
||||
addOn: boolean;
|
||||
|
|
@ -46,6 +48,8 @@ export interface WorkOrderAdvancedFilters {
|
|||
internalOnly: boolean;
|
||||
hasUplift: boolean;
|
||||
upliftStatuses: Set<UpliftFilterStatus>;
|
||||
/** Exact work orders to show; when non-empty the server ignores every other filter. */
|
||||
workOrderIds: Set<number>;
|
||||
}
|
||||
|
||||
export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
||||
|
|
@ -59,6 +63,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
|||
dispatchers: new Set(),
|
||||
regions: new Set(),
|
||||
statuses: new Set(),
|
||||
pastDue: false,
|
||||
rescheduled: false,
|
||||
carriedOver: false,
|
||||
addOn: false,
|
||||
|
|
@ -70,6 +75,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
|||
internalOnly: false,
|
||||
hasUplift: false,
|
||||
upliftStatuses: new Set(),
|
||||
workOrderIds: new Set(),
|
||||
};
|
||||
|
||||
export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const WORK_ORDER_TYPE_API = {
|
|||
Inspection: 5,
|
||||
Reactive: 6,
|
||||
AddOn: 7,
|
||||
Overdue: 8,
|
||||
Other: 99,
|
||||
} as const;
|
||||
|
||||
|
|
@ -64,6 +65,7 @@ export const WORK_ORDER_TYPE_API_TO_FE: Record<number, WOType | ""> = {
|
|||
[WORK_ORDER_TYPE_API.PM]: "PM",
|
||||
[WORK_ORDER_TYPE_API.Reactive]: "Reactive",
|
||||
[WORK_ORDER_TYPE_API.Emergency]: "Emergency",
|
||||
[WORK_ORDER_TYPE_API.Overdue]: "Overdue",
|
||||
};
|
||||
|
||||
export const LIFECYCLE_STATUS_API_TO_FE: Record<number, WizardWOStatus | "Canceled"> = {
|
||||
|
|
@ -326,6 +328,8 @@ export interface WorkOrderBoardSearchQueryParams {
|
|||
hasUplift?: boolean;
|
||||
upliftStatuses?: string[];
|
||||
myWorkOrders?: boolean;
|
||||
/** Comma-separated work-order ids; when present the server returns exactly those, ignoring other filters. */
|
||||
ids?: string;
|
||||
sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string;
|
||||
sortDir?: "asc" | "desc" | string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,6 +192,15 @@ export function appendStatusPmVendorDocChips(
|
|||
clear: (current) => ({ ...current, statuses: new Set() }),
|
||||
});
|
||||
|
||||
if (filters.pastDue) {
|
||||
chips.push({
|
||||
key: "pastdue",
|
||||
group: "Status",
|
||||
value: "Past Due",
|
||||
clear: (current) => ({ ...current, pastDue: false }),
|
||||
});
|
||||
}
|
||||
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "pm",
|
||||
|
|
@ -306,3 +315,17 @@ export function appendUpliftChip(chips: AdvancedFilterChip[], filters: WorkOrder
|
|||
clear: clearUplift,
|
||||
});
|
||||
}
|
||||
|
||||
export function appendWorkOrderIdsChip(
|
||||
chips: AdvancedFilterChip[],
|
||||
filters: WorkOrderAdvancedFilters,
|
||||
) {
|
||||
const count = filters.workOrderIds?.size ?? 0;
|
||||
if (count === 0) return;
|
||||
chips.push({
|
||||
key: "workOrderIds",
|
||||
group: "Work orders",
|
||||
value: `Showing ${count} selected work order${count === 1 ? "" : "s"}`,
|
||||
clear: (current) => ({ ...current, workOrderIds: new Set<number>() }),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,12 +45,10 @@ const matchesDateRange: AdvancedFilterPredicate = (row, filters) => {
|
|||
const matchesSite: AdvancedFilterPredicate = (row, filters) =>
|
||||
!filters.sites.size || filters.sites.has(row.site);
|
||||
|
||||
const matchesType: AdvancedFilterPredicate = (row, filters) => {
|
||||
if (!filters.types.size) return true;
|
||||
const overdueMatch = filters.types.has("Overdue") && row.isPastDue;
|
||||
const typeMatch = filters.types.has(row.type as never);
|
||||
return overdueMatch || typeMatch;
|
||||
};
|
||||
const matchesType: AdvancedFilterPredicate = (row, filters) =>
|
||||
!filters.types.size || filters.types.has(row.type as never);
|
||||
|
||||
const matchesPastDue: AdvancedFilterPredicate = (row, filters) => !filters.pastDue || row.isPastDue;
|
||||
|
||||
const matchesDispatcher: AdvancedFilterPredicate = (row, filters) => {
|
||||
if (!filters.dispatchers.size) return true;
|
||||
|
|
@ -95,6 +93,7 @@ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [
|
|||
matchesType,
|
||||
matchesDispatcher,
|
||||
matchesStatus,
|
||||
matchesPastDue,
|
||||
matchesPmType,
|
||||
matchesVendorTech,
|
||||
matchesDoc,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
appendSeverityChip,
|
||||
appendStatusPmVendorDocChips,
|
||||
appendUpliftChip,
|
||||
appendWorkOrderIdsChip,
|
||||
type AdvancedFilterChip,
|
||||
} from "@/domain/work-orders/utils/advanced-filter-chip-builders";
|
||||
import { addDaysIso, mondayOf, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
|
@ -33,7 +34,9 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte
|
|||
docs: new Set(filters.docs),
|
||||
flagColors: new Set(filters.flagColors ?? []),
|
||||
upliftStatuses: new Set(filters.upliftStatuses ?? []),
|
||||
workOrderIds: new Set(filters.workOrderIds ?? []),
|
||||
unscheduledOnly: Boolean(filters.unscheduledOnly),
|
||||
pastDue: Boolean(filters.pastDue),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +48,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool
|
|||
Boolean(filters.dispatchers?.size),
|
||||
Boolean(filters.regions?.size),
|
||||
Boolean(filters.statuses?.size),
|
||||
Boolean(filters.pastDue),
|
||||
filters.rescheduled,
|
||||
filters.carriedOver,
|
||||
filters.addOn,
|
||||
|
|
@ -56,12 +60,25 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool
|
|||
filters.internalOnly,
|
||||
filters.hasUplift,
|
||||
filters.unscheduledOnly,
|
||||
Boolean(filters.workOrderIds?.size),
|
||||
];
|
||||
|
||||
export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number {
|
||||
return ADVANCED_FILTER_ACTIVE_SIGNALS(filters).filter(Boolean).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server honours only the id set when one is sent, so an id selection and panel facets never
|
||||
* combine: applying any facet replaces the selection with a normal filtered board.
|
||||
*/
|
||||
export function resolveAppliedAdvancedFilters(
|
||||
filters: WorkOrderAdvancedFilters,
|
||||
): WorkOrderAdvancedFilters {
|
||||
const next = cloneAdvancedFilters(filters);
|
||||
const withoutIds = { ...next, workOrderIds: new Set<number>() };
|
||||
return next.workOrderIds.size > 0 && countAdvancedFilters(withoutIds) > 0 ? withoutIds : next;
|
||||
}
|
||||
|
||||
function monthStart(y: number, m: number): string {
|
||||
return `${y}-${pad2(m + 1)}-01`;
|
||||
}
|
||||
|
|
@ -120,6 +137,7 @@ export function buildAdvancedFilterChips(
|
|||
appendIndicatorChips(chips, filters);
|
||||
appendFlagAndInternalChips(chips, filters);
|
||||
appendUpliftChip(chips, filters);
|
||||
appendWorkOrderIdsChip(chips, filters);
|
||||
|
||||
return chips;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from "@/domain/work-orders/types/work-order-board";
|
||||
import { mapWizardTypeToApi } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
|
||||
import { normalizeWorkOrderIds } from "@/domain/work-orders/utils/work-order-ids-filter";
|
||||
import type { CompletionDocFilter } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
|
|
@ -41,30 +42,19 @@ function mapDispatchers(ids: Iterable<string>): string[] | undefined {
|
|||
return list.length > 0 ? list : undefined;
|
||||
}
|
||||
|
||||
/** Maps UI type filters to API `types` + separate `overdue` flag. Overdue is never sent as types=99. */
|
||||
function mapWoTypeFilters(types: Iterable<WOType | string>): {
|
||||
types?: number[];
|
||||
overdue?: boolean;
|
||||
} {
|
||||
/** Maps UI type filters to API `types`. The Overdue type is sent as its own enum; past due uses `overdue`. */
|
||||
function mapWoTypeFilters(types: Iterable<WOType | string>): number[] | undefined {
|
||||
const list: number[] = [];
|
||||
let overdue = false;
|
||||
for (const type of types) {
|
||||
if (type === "All") {
|
||||
continue;
|
||||
}
|
||||
if (type === "Overdue") {
|
||||
overdue = true;
|
||||
continue;
|
||||
}
|
||||
const api = mapWizardTypeToApi(type as WOType);
|
||||
if (api != null) {
|
||||
list.push(api);
|
||||
}
|
||||
}
|
||||
return {
|
||||
types: list.length > 0 ? list : undefined,
|
||||
overdue: overdue || undefined,
|
||||
};
|
||||
return list.length > 0 ? list : undefined;
|
||||
}
|
||||
|
||||
function mapStatuses(statuses: Iterable<WizardWOStatus | string>): number[] | undefined {
|
||||
|
|
@ -133,20 +123,24 @@ function mapSearchFacetParams(
|
|||
};
|
||||
}
|
||||
|
||||
/** Sent as one comma-separated `ids` value; the server then ignores every other filter. */
|
||||
function mapWorkOrderIds(ids: Set<number> | undefined): string | undefined {
|
||||
// Normalized here too, so a caller that fills the set directly still stays within the server's limit.
|
||||
const normalized = ids?.size ? normalizeWorkOrderIds(ids) : [];
|
||||
return normalized.length > 0 ? normalized.join(",") : undefined;
|
||||
}
|
||||
|
||||
/** Build GET /board query from weekly filter bar. */
|
||||
export function toBoardQueryParams(
|
||||
bar: WorkOrderBarFilters,
|
||||
search: string,
|
||||
): WorkOrderBoardQueryParams {
|
||||
const [, weekTo] = weekRange(bar.weekMonday);
|
||||
const typeFilters = bar.types.size > 0 ? mapWoTypeFilters(bar.types) : {};
|
||||
|
||||
return {
|
||||
weekStart: bar.weekMonday,
|
||||
weekEnd: weekTo,
|
||||
dispatchers: mapDispatchers(bar.dispatcherIds),
|
||||
types: typeFilters.types,
|
||||
overdue: typeFilters.overdue,
|
||||
types: mapWoTypeFilters(bar.types),
|
||||
search: search.trim().length >= 2 ? search.trim() : undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -203,7 +197,6 @@ export function toBoardSearchQueryParams(input: {
|
|||
BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||
);
|
||||
|
||||
const typeFilters = mapWoTypeFilters(filters.types);
|
||||
const trimmedSearch = input.search?.trim();
|
||||
const search = trimmedSearch && trimmedSearch.length >= 2 ? trimmedSearch : undefined;
|
||||
|
||||
|
|
@ -216,8 +209,8 @@ export function toBoardSearchQueryParams(input: {
|
|||
dateTo: date.dateTo,
|
||||
includeDateless: date.includeDateless,
|
||||
sites: filters.sites.size > 0 ? [...filters.sites] : undefined,
|
||||
types: typeFilters.types,
|
||||
overdue: typeFilters.overdue,
|
||||
types: mapWoTypeFilters(filters.types),
|
||||
overdue: filters.pastDue || undefined,
|
||||
dispatchers: mapDispatchers(filters.dispatchers),
|
||||
regions: filters.regions.size > 0 ? [...filters.regions] : undefined,
|
||||
statuses: mapStatuses(filters.statuses),
|
||||
|
|
@ -226,6 +219,7 @@ export function toBoardSearchQueryParams(input: {
|
|||
docStatuses: mapDocStatuses(filters.docs),
|
||||
unscheduledOnly: filters.unscheduledOnly || undefined,
|
||||
...mapSearchFacetParams(filters),
|
||||
ids: mapWorkOrderIds(filters.workOrderIds),
|
||||
sortBy: input.sortBy || "scheduledDate",
|
||||
sortDir: input.sortDir === "desc" ? "desc" : "asc",
|
||||
};
|
||||
|
|
@ -252,10 +246,6 @@ export function buildBoardSearchParamsRecord(
|
|||
return out;
|
||||
}
|
||||
|
||||
export function woTypeFilterIsOverdue(type: string): boolean {
|
||||
return type === "Overdue";
|
||||
}
|
||||
|
||||
/** Week-independent GET /board/search params for the pinned Unassigned queue. */
|
||||
export function toUnassignedQueueQueryParams(
|
||||
page = 0,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { ALL_WIZARD_STATUSES, WO_TYPES } from "@/domain/work-orders/types/work-o
|
|||
import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type { UpliftFilterStatus } from "@/domain/work-orders/types/work-order-uplift-filter";
|
||||
import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||
import { parseWorkOrderIdsParam } from "@/domain/work-orders/utils/work-order-ids-filter";
|
||||
|
||||
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
|
|
@ -63,6 +64,12 @@ function parseUpliftStatuses(raw: string | null): UpliftFilterStatus[] {
|
|||
export function parseDashboardDrilldownParams(
|
||||
params: URLSearchParams,
|
||||
): WorkOrderAdvancedFilters | null {
|
||||
// An exact id set replaces every other filter: nothing else may hide one of those work orders.
|
||||
const workOrderIds = parseWorkOrderIdsParam(params.get("ids"));
|
||||
if (workOrderIds.length > 0) {
|
||||
return { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), workOrderIds: new Set(workOrderIds) };
|
||||
}
|
||||
|
||||
const dateFrom = (params.get("dateFrom") ?? "").trim();
|
||||
const dateTo = (params.get("dateTo") ?? "").trim();
|
||||
const hasDateRange = ISO_DATE_PATTERN.test(dateFrom) && ISO_DATE_PATTERN.test(dateTo);
|
||||
|
|
|
|||
|
|
@ -65,12 +65,7 @@ function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean {
|
|||
}
|
||||
|
||||
function rowMatchesBarTypes(row: WorkOrderTableRow, types: Set<WOType>): boolean {
|
||||
if (types.size === 0) {
|
||||
return true;
|
||||
}
|
||||
const overdueMatch = types.has("Overdue") && row.isPastDue;
|
||||
const typeMatch = types.has(row.type as WOType);
|
||||
return overdueMatch || typeMatch;
|
||||
return types.size === 0 || types.has(row.type as WOType);
|
||||
}
|
||||
|
||||
export function filterByBarAndSearch(
|
||||
|
|
|
|||
26
src/domain/work-orders/utils/work-order-ids-filter.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/** The board's exact id filter accepts at most this many distinct work orders (backend limit). */
|
||||
export const WORK_ORDER_IDS_MAX = 200;
|
||||
|
||||
/** Positive integer ids, deduplicated in first-seen order, capped at {@link WORK_ORDER_IDS_MAX}. */
|
||||
export function normalizeWorkOrderIds(values: Iterable<number | string>): number[] {
|
||||
const ids = new Set<number>();
|
||||
for (const value of values) {
|
||||
const text = typeof value === "number" ? String(value) : value.trim();
|
||||
if (!/^\d+$/.test(text)) continue;
|
||||
const id = Number(text);
|
||||
if (Number.isSafeInteger(id) && id > 0) ids.add(id);
|
||||
if (ids.size === WORK_ORDER_IDS_MAX) break;
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/** Reads the `ids` URL param (`101,205,318`); invalid tokens are dropped. */
|
||||
export function parseWorkOrderIdsParam(raw: string | null): number[] {
|
||||
return raw ? normalizeWorkOrderIds(raw.split(",")) : [];
|
||||
}
|
||||
|
||||
/** Work Orders showing exactly these work orders, whatever the saved or default filters are. */
|
||||
export function buildWorkOrderIdsHref(ids: number[]): string {
|
||||
const normalized = normalizeWorkOrderIds(ids);
|
||||
return normalized.length > 0 ? `/workorders?ids=${normalized.join(",")}` : "/workorders";
|
||||
}
|
||||
144
src/test/app/(protected)/account/my-account-page.test.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { toast } from "react-toastify";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import MyAccountPage from "@/app/(protected)/account";
|
||||
import * as authApi from "@/domain/auth/api/auth-api";
|
||||
import type { AuthUser } from "@/domain/auth/types/auth-user";
|
||||
import { createAppQueryClient } from "@/lib/query/query-client";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const { toastSuccessMock } = vi.hoisted(() => ({ toastSuccessMock: vi.fn() }));
|
||||
|
||||
const SIGNED_IN_USER: AuthUser = {
|
||||
token: "test-jwt-token",
|
||||
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
email: "dana@seahavenind.com",
|
||||
userRoles: "Dispatcher",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Dana Reyes",
|
||||
id: "user-1",
|
||||
};
|
||||
|
||||
vi.mock("@/domain/auth/api/auth-api", () => ({
|
||||
getSession: vi.fn(),
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-toastify", () => ({
|
||||
toast: { success: toastSuccessMock, error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
/** Uses the app's query client, so the global mutation error toast is live. */
|
||||
function renderPage() {
|
||||
return renderWithProviders(<MyAccountPage />, {
|
||||
route: "/account",
|
||||
queryClient: createAppQueryClient(),
|
||||
});
|
||||
}
|
||||
|
||||
function field(label: RegExp) {
|
||||
return screen.getByLabelText(label, { selector: "input" });
|
||||
}
|
||||
|
||||
async function fillPasswords(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
{ current, next, confirm }: { current: string; next: string; confirm: string },
|
||||
) {
|
||||
await user.type(field(/^current password/i), current);
|
||||
await user.type(field(/^new password/i), next);
|
||||
await user.type(field(/^confirm new password/i), confirm);
|
||||
}
|
||||
|
||||
describe("My Account page", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(authApi.getSession).mockResolvedValue(SIGNED_IN_USER);
|
||||
});
|
||||
|
||||
it("shows the signed-in user's name, role and email as read-only details", async () => {
|
||||
renderPage();
|
||||
|
||||
const details = await screen.findByRole("region", { name: "Account details" });
|
||||
expect(within(details).getByText("Dana Reyes")).toBeInTheDocument();
|
||||
expect(within(details).getByText("Dispatcher")).toBeInTheDocument();
|
||||
expect(within(details).getByText("dana@seahavenind.com")).toBeInTheDocument();
|
||||
expect(within(details).queryByRole("textbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the live checklist for the new password", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const checklist = screen.getByRole("list", { name: "Password requirements" });
|
||||
expect(within(checklist).getAllByText("requirement not met")).toHaveLength(4);
|
||||
|
||||
await user.type(field(/^new password/i), "Abc1!x");
|
||||
|
||||
expect(within(checklist).getAllByText("requirement met")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("keeps Update password disabled until every rule is met and the confirmation matches", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
const submit = screen.getByRole("button", { name: "Update password" });
|
||||
|
||||
await fillPasswords(user, { current: "Current1!", next: "abcdef", confirm: "abcdef" });
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
await user.clear(field(/^new password/i));
|
||||
await user.type(field(/^new password/i), "Abc1!x");
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
await user.clear(field(/^confirm new password/i));
|
||||
await user.type(field(/^confirm new password/i), "Abc1!x");
|
||||
expect(submit).toBeEnabled();
|
||||
expect(authApi.changePassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tells the user when the confirmation does not match", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await fillPasswords(user, { current: "Current1!", next: "Abc1!x", confirm: "Abc1!y" });
|
||||
await user.tab();
|
||||
|
||||
expect(await screen.findByText("Passwords don't match")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends the current and new password and clears the form after a successful change", async () => {
|
||||
vi.mocked(authApi.changePassword).mockResolvedValue({ status: "Success " });
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await fillPasswords(user, { current: "Current1!", next: "Abc1!x", confirm: "Abc1!x" });
|
||||
await user.click(screen.getByRole("button", { name: "Update password" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(authApi.changePassword).toHaveBeenCalledWith({
|
||||
Currentpassword: "Current1!",
|
||||
Newpassword: "Abc1!x",
|
||||
Confirmpassword: "Abc1!x",
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(field(/^current password/i)).toHaveValue(""));
|
||||
expect(toastSuccessMock).toHaveBeenCalledWith("Password updated");
|
||||
});
|
||||
|
||||
it("shows the server's current-password rejection on the current password field", async () => {
|
||||
vi.mocked(authApi.changePassword).mockRejectedValue(new Error("Current password is incorrect"));
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await fillPasswords(user, { current: "Wrong1!", next: "Abc1!x", confirm: "Abc1!x" });
|
||||
await user.click(screen.getByRole("button", { name: "Update password" }));
|
||||
|
||||
expect(await screen.findByText("Current password is incorrect")).toBeInTheDocument();
|
||||
expect(field(/^current password/i)).toHaveAttribute("aria-invalid", "true");
|
||||
expect(toastSuccessMock).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { HTTPError } from "ky";
|
||||
import { toast } from "react-toastify";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AddSiteDialog } from "@/app/(protected)/locations/_components/add-site-dialog";
|
||||
import { normalizeApiRequestError } from "@/api/api-error";
|
||||
import { formatStateOptionLabel, type StateCode } from "@/data/areas";
|
||||
import { suggestAddresses } from "@/lib/address/vendor-address";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const create = vi.fn();
|
||||
|
|
@ -57,7 +60,7 @@ async function fillRequiredFields(dialog: HTMLElement) {
|
|||
target: { value: "dal1" },
|
||||
});
|
||||
await pickOption("Client", "Acme Logistics", "Acme");
|
||||
fireEvent.change(within(dialog).getByRole("textbox", { name: "Street Address" }), {
|
||||
fireEvent.change(within(dialog).getByRole("combobox", { name: "Street Address" }), {
|
||||
target: { value: "3811 Distribution Dr" },
|
||||
});
|
||||
fireEvent.change(within(dialog).getByRole("textbox", { name: "City" }), {
|
||||
|
|
@ -112,6 +115,46 @@ describe("AddSiteDialog", () => {
|
|||
expect(within(dialog).getByText("Area: West")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("suggests addresses from 3 typed characters and fills Street, City and State on pick", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { dialog } = renderDialog();
|
||||
const street = within(dialog).getByRole("combobox", { name: "Street Address" });
|
||||
|
||||
await user.type(street, "38");
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
|
||||
await user.type(street, "1");
|
||||
const picked = suggestAddresses("381")[0];
|
||||
if (picked === undefined) throw new Error("expected a suggestion for 3 typed characters");
|
||||
const listbox = await screen.findByRole("listbox");
|
||||
await user.click(within(listbox).getByRole("option", { name: picked.label }));
|
||||
|
||||
await waitFor(() => expect(street).toHaveValue("381"));
|
||||
expect(within(dialog).getByRole("textbox", { name: "City" })).toHaveValue(picked.city);
|
||||
expect(within(dialog).getByRole("combobox", { name: "State" })).toHaveValue(
|
||||
formatStateOptionLabel(picked.state as StateCode),
|
||||
);
|
||||
expect(within(dialog).getByTitle("Site location map")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps typed Street Address text when no suggestion is picked", async () => {
|
||||
create.mockResolvedValue({});
|
||||
const user = userEvent.setup();
|
||||
const { dialog, onClose } = renderDialog();
|
||||
|
||||
await fillRequiredFields(dialog);
|
||||
const street = within(dialog).getByRole("combobox", { name: "Street Address" });
|
||||
await user.clear(street);
|
||||
await user.type(street, "12 Custom Ln");
|
||||
await user.keyboard("{Escape}");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Add Site" }));
|
||||
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ address: "12 Custom Ln", city: "Dallas", state: "TX" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates the site with an upper-cased code, optional fields blank, then closes", async () => {
|
||||
create.mockResolvedValue({});
|
||||
const { dialog, onClose } = renderDialog();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import LocationsListPage from "@/app/(protected)/locations/index";
|
||||
import type { Location, LocationListItem } from "@/domain/locations/types/location";
|
||||
import { suggestAddresses } from "@/lib/address/vendor-address";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const getList = vi.fn();
|
||||
|
|
@ -162,6 +164,37 @@ describe("Sites page detail panel", () => {
|
|||
expect(within(region).getByText("Fort Worth")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("edit mode: picking an address suggestion fills Street, City and State and enables Save", async () => {
|
||||
update.mockResolvedValue({});
|
||||
getById.mockResolvedValue(dallas);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LocationsListPage />, { withAuth: false });
|
||||
const region = await openSite("DAL1");
|
||||
fireEvent.click(within(region).getByRole("button", { name: "Edit" }));
|
||||
const save = within(region).getByRole("button", { name: "Save changes" });
|
||||
|
||||
const street = within(region).getByRole("combobox", { name: "Street Address" });
|
||||
await user.clear(street);
|
||||
await user.type(street, "415 Murphy Rd");
|
||||
const picked = suggestAddresses("415 Murphy Rd").find((option) => option.city !== "Dallas");
|
||||
if (picked === undefined) throw new Error("expected a non-Dallas suggestion");
|
||||
await user.click(await screen.findByRole("option", { name: picked.label }));
|
||||
|
||||
await waitFor(() => expect(save).toBeEnabled());
|
||||
expect(within(region).getByRole("textbox", { name: "City" })).toHaveValue(picked.city);
|
||||
fireEvent.click(save);
|
||||
|
||||
await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
"1",
|
||||
expect.objectContaining({
|
||||
address: "415 Murphy Rd",
|
||||
city: picked.city,
|
||||
state: picked.state,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("Cancel discards edits and returns to view without an API call", async () => {
|
||||
renderWithProviders(<LocationsListPage />, { withAuth: false });
|
||||
const region = await openSite("DAL1");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
|
||||
import { AdvancedSearchFilterSections } from "@/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections";
|
||||
import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
|
||||
import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
|
||||
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
|
||||
import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import {
|
||||
EMPTY_ADVANCED_FILTERS,
|
||||
type WorkOrderAdvancedFilters,
|
||||
} from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
canContinueWizardStep1,
|
||||
EMPTY_WIZARD_DRAFT,
|
||||
type WorkOrderWizardDraft,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { buildAdvancedFilterChips } from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||
import { getClosabilityGaps } from "@/domain/work-orders/utils/wo-closability";
|
||||
|
||||
const USERS = [{ id: "sarah", name: "Sarah Chen" }];
|
||||
|
||||
describe("Overdue work order type", () => {
|
||||
it("offers Overdue in the Create WO wizard and never asks for a severity", () => {
|
||||
const onPatch = vi.fn();
|
||||
const { rerender } = render(
|
||||
<WizardStepTypeSchedule
|
||||
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM" }}
|
||||
users={USERS}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={onPatch}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /No client response — due date passed/ }));
|
||||
expect(onPatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "Overdue", severity: null }),
|
||||
);
|
||||
|
||||
const overdueDraft: WorkOrderWizardDraft = { ...EMPTY_WIZARD_DRAFT, type: "Overdue" };
|
||||
rerender(
|
||||
<WizardStepTypeSchedule
|
||||
draft={overdueDraft}
|
||||
users={USERS}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={onPatch}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText(/Severity \(from APM\)/)).not.toBeInTheDocument();
|
||||
expect(canContinueWizardStep1(overdueDraft)).toBe(true);
|
||||
});
|
||||
|
||||
it("creates the work order with the Overdue enum and no severity", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "Overdue",
|
||||
severity: 2,
|
||||
locationId: "1",
|
||||
siteCode: "BK5",
|
||||
});
|
||||
|
||||
expect(payload.workOrderType).toBe(WORK_ORDER_TYPE_API.Overdue);
|
||||
expect(payload.severity ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it("corrects a type inline to Overdue without opening the severity dialog", async () => {
|
||||
const onSave = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<TypeCell value="Reactive" severity={3} onSave={onSave} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Type of WO, Reactive" }));
|
||||
await user.click(screen.getByRole("button", { name: /^Overdue$/ }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith("Overdue", null);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reads the Overdue enum back from the board as the Overdue type", () => {
|
||||
const row = mapWorkOrderTableRow({ id: 1, workOrderType: WORK_ORDER_TYPE_API.Overdue });
|
||||
expect(row.type).toBe("Overdue");
|
||||
});
|
||||
|
||||
it("closes with the completion document, not photos", () => {
|
||||
const base = {
|
||||
id: "1",
|
||||
dispatcherId: "sarah",
|
||||
dispatcherName: "Sarah Chen",
|
||||
company: "Gateway Plumbing",
|
||||
vendorId: "",
|
||||
tech: "",
|
||||
completedDate: "2026-09-24",
|
||||
woNumber: "24971",
|
||||
pm: "Backflow Preventers",
|
||||
mediaCount: 0,
|
||||
};
|
||||
expect(
|
||||
getClosabilityGaps({ ...base, type: "Overdue" as const, docStatus: "No" as const }),
|
||||
).toEqual(["Completion document"]);
|
||||
expect(
|
||||
getClosabilityGaps({ ...base, type: "Overdue" as const, docStatus: "Yes" as const }),
|
||||
).toEqual([]);
|
||||
|
||||
const row = {
|
||||
id: "1",
|
||||
type: "Overdue",
|
||||
pm: "Backflow Preventers",
|
||||
docStatus: "No",
|
||||
rowVersion: "1",
|
||||
};
|
||||
render(
|
||||
<SlideOverCompletionTab
|
||||
row={row as WorkOrderTableRow}
|
||||
isReadOnly={false}
|
||||
media={[]}
|
||||
onOpenCompletionDoc={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Completion Document")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /open completion document/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("keeps a separate Past Due status toggle in the advanced filters", () => {
|
||||
let draft: WorkOrderAdvancedFilters = { ...EMPTY_ADVANCED_FILTERS };
|
||||
const setDraft = vi.fn(
|
||||
(update: (prev: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters) => {
|
||||
draft = update(draft);
|
||||
},
|
||||
);
|
||||
render(
|
||||
<AdvancedSearchFilterSections
|
||||
draft={draft}
|
||||
setDraft={setDraft as never}
|
||||
toggleIn={vi.fn()}
|
||||
setMany={vi.fn()}
|
||||
sites={[]}
|
||||
users={[]}
|
||||
pmTypes={[]}
|
||||
vendorTechs={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Status/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Past Due/ }));
|
||||
|
||||
expect(draft.pastDue).toBe(true);
|
||||
expect(draft.types.size).toBe(0);
|
||||
expect(buildAdvancedFilterChips(draft)).toContainEqual(
|
||||
expect.objectContaining({ group: "Status", value: "Past Due" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useLocation } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
|
||||
import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params";
|
||||
import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters";
|
||||
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||
import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||
import { buildBoardSearchParamsRecord } from "@/domain/work-orders/utils/board-query-params";
|
||||
import {
|
||||
buildWorkOrderIdsHref,
|
||||
WORK_ORDER_IDS_MAX,
|
||||
} from "@/domain/work-orders/utils/work-order-ids-filter";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
/** The Work Orders page's filter wiring, without the table: URL → applied filters → board/search params. */
|
||||
function BoardFilterHarness() {
|
||||
const filters = useWorkOrdersListFilters();
|
||||
useDashboardDrilldownFilters(filters.applyAdvancedFilters);
|
||||
const location = useLocation();
|
||||
const params = buildResolvedBoardSearchParams({
|
||||
advApplied: filters.advApplied,
|
||||
search: "",
|
||||
page: 0,
|
||||
pageSize: 200,
|
||||
sortBy: "",
|
||||
sortDir: "",
|
||||
locations: [],
|
||||
vendors: [],
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<output aria-label="Board search params">
|
||||
{params == null ? "weekly board" : JSON.stringify(buildBoardSearchParamsRecord(params))}
|
||||
</output>
|
||||
<output aria-label="Current search">{location.search}</output>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => filters.applyAdvancedFilters({ ...filters.advInitial, rescheduled: true })}
|
||||
>
|
||||
Apply rescheduled only
|
||||
</button>
|
||||
<button type="button" onClick={() => filters.applyAdvancedFilters(filters.advInitial)}>
|
||||
Apply unchanged
|
||||
</button>
|
||||
{filters.advApplied != null && (
|
||||
<WorkOrdersAdvancedFiltersBanner
|
||||
applied={filters.advApplied}
|
||||
totalCount={2}
|
||||
sites={[]}
|
||||
users={[]}
|
||||
onClearChip={filters.clearAdvancedFilterChip}
|
||||
onClearAll={filters.clearAdvancedFilters}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function searchParams(): Record<string, unknown> | "weekly board" {
|
||||
const text = screen.getByRole("status", { name: "Board search params" }).textContent ?? "";
|
||||
return text === "weekly board" ? text : (JSON.parse(text) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
describe("Work Orders exact id filter", () => {
|
||||
it("opens the board on exactly the linked work orders and shows the selection chip", async () => {
|
||||
renderWithProviders(<BoardFilterHarness />, {
|
||||
route: "/workorders?ids=4,9,4",
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(searchParams()).not.toBe("weekly board"));
|
||||
const params = searchParams() as Record<string, unknown>;
|
||||
expect(params.ids).toBe("4,9");
|
||||
// No saved or default facet travels with the id set.
|
||||
expect(params.statuses).toBeUndefined();
|
||||
expect(params.dispatchers).toBeUndefined();
|
||||
expect(params.types).toBeUndefined();
|
||||
|
||||
expect(screen.getByText("Showing 2 selected work orders")).toBeInTheDocument();
|
||||
// The applied link params are consumed, like the dashboard drill-downs.
|
||||
expect(screen.getByRole("status", { name: "Current search" })).toHaveTextContent(/^$/);
|
||||
});
|
||||
|
||||
it("returns to the weekly board when the selection chip is cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<BoardFilterHarness />, {
|
||||
route: buildWorkOrderIdsHref([12]),
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Showing 1 selected work order")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Remove filter" }));
|
||||
|
||||
expect(searchParams()).toBe("weekly board");
|
||||
expect(screen.queryByText(/selected work order/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("replaces the selection when a panel filter is applied, so no chip shows a filter the server ignores", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<BoardFilterHarness />, {
|
||||
route: "/workorders?ids=4,9",
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Showing 2 selected work orders")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Apply rescheduled only" }));
|
||||
|
||||
const params = searchParams() as Record<string, unknown>;
|
||||
expect(params.ids).toBeUndefined();
|
||||
expect(params.rescheduled).toBe(true);
|
||||
expect(screen.queryByText(/selected work order/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the selection when the panel is applied without a filter", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<BoardFilterHarness />, {
|
||||
route: "/workorders?ids=4,9",
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Showing 2 selected work orders")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Apply unchanged" }));
|
||||
|
||||
expect((searchParams() as Record<string, unknown>).ids).toBe("4,9");
|
||||
expect(screen.getByText("Showing 2 selected work orders")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("never sends more ids than the server accepts, however the selection was built", () => {
|
||||
const ids = new Set(Array.from({ length: WORK_ORDER_IDS_MAX + 30 }, (_, index) => index + 1));
|
||||
const params = buildResolvedBoardSearchParams({
|
||||
advApplied: { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), workOrderIds: ids },
|
||||
search: "",
|
||||
page: 0,
|
||||
pageSize: 200,
|
||||
sortBy: "",
|
||||
sortDir: "",
|
||||
locations: [],
|
||||
vendors: [],
|
||||
});
|
||||
|
||||
expect(params?.ids?.split(",")).toHaveLength(WORK_ORDER_IDS_MAX);
|
||||
});
|
||||
|
||||
it("ignores other drill-down params sent alongside the id set", async () => {
|
||||
renderWithProviders(<BoardFilterHarness />, {
|
||||
route: "/workorders?ids=5&statuses=Scheduled&dispatchers=u1",
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(searchParams()).not.toBe("weekly board"));
|
||||
const params = searchParams() as Record<string, unknown>;
|
||||
expect(params.ids).toBe("5");
|
||||
expect(params.statuses).toBeUndefined();
|
||||
expect(params.dispatchers).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the regular drill-down filters when no ids are given", async () => {
|
||||
renderWithProviders(<BoardFilterHarness />, {
|
||||
route: "/workorders?statuses=Scheduled",
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(searchParams()).not.toBe("weekly board"));
|
||||
const params = searchParams() as Record<string, unknown>;
|
||||
expect(params.ids).toBeUndefined();
|
||||
expect(params.statuses).toEqual([expect.any(Number)]);
|
||||
});
|
||||
});
|
||||
99
src/test/components/auth/password-checklist.test.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { useState } from "react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PasswordChecklist } from "@/components/auth/password-checklist";
|
||||
|
||||
function ChecklistHarness() {
|
||||
const [password, setPassword] = useState("");
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="pw">Password</label>
|
||||
<input id="pw" value={password} onChange={(event) => setPassword(event.target.value)} />
|
||||
<PasswordChecklist password={password} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ruleStates(): Record<string, string | null> {
|
||||
const list = screen.getByRole("list", { name: "Password requirements" });
|
||||
return Object.fromEntries(
|
||||
within(list)
|
||||
.getAllByRole("listitem")
|
||||
.map((item) => [item.getAttribute("data-rule") ?? "", item.getAttribute("data-met")]),
|
||||
);
|
||||
}
|
||||
|
||||
describe("PasswordChecklist", () => {
|
||||
it("starts with every rule unmet", () => {
|
||||
render(<ChecklistHarness />);
|
||||
|
||||
expect(ruleStates()).toEqual({
|
||||
length: "false",
|
||||
uppercase: "false",
|
||||
number: "false",
|
||||
special: "false",
|
||||
});
|
||||
expect(screen.getAllByText("requirement not met")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("marks each rule met live as the user types", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ChecklistHarness />);
|
||||
const input = screen.getByLabelText("Password");
|
||||
|
||||
await user.type(input, "A");
|
||||
expect(ruleStates()).toMatchObject({ uppercase: "true", number: "false", special: "false" });
|
||||
|
||||
await user.type(input, "1");
|
||||
expect(ruleStates()).toMatchObject({ uppercase: "true", number: "true", special: "false" });
|
||||
|
||||
await user.type(input, "!");
|
||||
expect(ruleStates()).toMatchObject({ special: "true", length: "false" });
|
||||
|
||||
await user.type(input, "xyz");
|
||||
expect(ruleStates()).toEqual({
|
||||
length: "true",
|
||||
uppercase: "true",
|
||||
number: "true",
|
||||
special: "true",
|
||||
});
|
||||
expect(screen.getAllByText("requirement met")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("announces only the rules whose state changed through a polite status region", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ChecklistHarness />);
|
||||
const input = screen.getByLabelText("Password");
|
||||
const status = screen.getByRole("status");
|
||||
|
||||
expect(status).toHaveAttribute("aria-live", "polite");
|
||||
expect(status).toHaveTextContent(/^$/);
|
||||
|
||||
await user.type(input, "A");
|
||||
expect(status).toHaveTextContent(/^One uppercase letter: requirement met$/);
|
||||
|
||||
await user.type(input, "b");
|
||||
expect(status).toHaveTextContent(/^One uppercase letter: requirement met$/);
|
||||
|
||||
await user.type(input, "1!xy");
|
||||
expect(status).toHaveTextContent(/^At least 6 characters: requirement met$/);
|
||||
|
||||
await user.clear(input);
|
||||
expect(status).toHaveTextContent(
|
||||
"At least 6 characters: requirement not met. One uppercase letter: requirement not met. One number: requirement not met. One special character: requirement not met",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks a rule unmet again when the user deletes the character that satisfied it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ChecklistHarness />);
|
||||
const input = screen.getByLabelText("Password");
|
||||
|
||||
await user.type(input, "Abcde1!");
|
||||
await user.type(input, "{Backspace}");
|
||||
|
||||
expect(ruleStates()).toMatchObject({ special: "false", length: "true", number: "true" });
|
||||
});
|
||||
});
|
||||
|
|
@ -26,6 +26,17 @@ function sidebarLinks(): Record<string, string> {
|
|||
}
|
||||
|
||||
describe("AppSidebar navigation entries", () => {
|
||||
it("links every signed-in user to My Account and marks it current on that page", () => {
|
||||
canApproveMock.mockReturnValue({ data: false });
|
||||
|
||||
renderSidebar("/account");
|
||||
const accountNav = screen.getByRole("navigation", { name: "Account navigation" });
|
||||
const link = within(accountNav).getByRole("link", { name: "My Account" });
|
||||
|
||||
expect(link).toHaveAttribute("href", "/account");
|
||||
expect(link).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
|
||||
it("shows Uplift Approvals for a user the backend says can approve uplifts", () => {
|
||||
canApproveMock.mockReturnValue({ data: true });
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ import { NotificationSessionProvider } from "@/domain/notifications/session/noti
|
|||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const apiGet = vi.hoisted(() => vi.fn());
|
||||
const apiPostNoContent = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/api", () => ({ apiGet }));
|
||||
vi.mock("@/api/api", () => ({ apiGet, apiPostNoContent }));
|
||||
|
||||
vi.mock("@/providers/auth-context", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/providers/auth-context")>()),
|
||||
|
|
@ -131,6 +132,8 @@ describe("notification center", () => {
|
|||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
apiGet.mockResolvedValue(standardFeed);
|
||||
apiPostNoContent.mockReset();
|
||||
apiPostNoContent.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("requests the signed-in user's feed and counts every unread item on the bell", async () => {
|
||||
|
|
@ -256,6 +259,58 @@ describe("notification center", () => {
|
|||
expect(within(panel).getByText(acknowledgeRow.title)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps Clear all disabled when only rows that need acknowledging are left", async () => {
|
||||
apiGet.mockResolvedValue(feed(section("sla", "SLA at Risk", [acknowledgeRow])));
|
||||
const user = userEvent.setup();
|
||||
renderBell();
|
||||
|
||||
const panel = await openPanel(user);
|
||||
|
||||
expect(within(panel).getByRole("button", { name: "Clear all" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("acknowledges a missed deadline through the API and removes the row", async () => {
|
||||
apiGet.mockResolvedValue(
|
||||
feed(
|
||||
section("sla", "SLA at Risk", [acknowledgeRow]),
|
||||
section("unassigned", "Unassigned", [unassigned]),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderBell();
|
||||
|
||||
const panel = await openPanel(user);
|
||||
const slaRow = within(panel).getByText(acknowledgeRow.title).closest("li") as HTMLElement;
|
||||
const acknowledge = within(slaRow).getByRole("button", { name: "Acknowledge SLA breach" });
|
||||
expect(acknowledge).toHaveAttribute("title", "Acknowledge");
|
||||
expect(acknowledge).toHaveTextContent("Acknowledge");
|
||||
|
||||
apiGet.mockResolvedValue(feed(section("unassigned", "Unassigned", [unassigned])));
|
||||
await user.click(acknowledge);
|
||||
|
||||
expect(apiPostNoContent).toHaveBeenCalledWith("notifications/sla/9/acknowledge");
|
||||
await waitFor(() =>
|
||||
expect(within(panel).queryByText(acknowledgeRow.title)).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(sectionLabels(panel)).toEqual(["Unassigned, 3"]);
|
||||
});
|
||||
|
||||
it("keeps the row when the acknowledgement fails", async () => {
|
||||
apiGet.mockResolvedValue(feed(section("sla", "SLA at Risk", [acknowledgeRow])));
|
||||
apiPostNoContent.mockRejectedValue(new Error("boom"));
|
||||
const user = userEvent.setup();
|
||||
renderBell();
|
||||
|
||||
const panel = await openPanel(user);
|
||||
await user.click(within(panel).getByRole("button", { name: "Acknowledge SLA breach" }));
|
||||
|
||||
await waitFor(() => expect(apiPostNoContent).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() =>
|
||||
expect(within(panel).getByRole("button", { name: "Acknowledge SLA breach" })).toBeEnabled(),
|
||||
);
|
||||
expect(within(panel).getByText(acknowledgeRow.title)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the unassigned queue with the existing board filters and closes the panel", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBell();
|
||||
|
|
|
|||
148
src/test/components/notifications/sla-at-risk-banner.test.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { act, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useLocation } from "react-router";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SlaAtRiskBanner } from "@/components/notifications/sla-at-risk-banner";
|
||||
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const apiGet = vi.hoisted(() => vi.fn());
|
||||
const toastWarn = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/api", () => ({ apiGet }));
|
||||
vi.mock("react-toastify", () => ({ toast: { warn: toastWarn } }));
|
||||
|
||||
const BANNER = "Work orders at risk of missing their response deadline";
|
||||
|
||||
function atRisk(id: number, number: string | null = String(1000 + id)) {
|
||||
return {
|
||||
id,
|
||||
number,
|
||||
severity: 1,
|
||||
startedAt: "2026-09-18T10:00:00Z",
|
||||
deadlineAt: "2026-09-18T12:00:00Z",
|
||||
percentElapsed: 60,
|
||||
};
|
||||
}
|
||||
|
||||
function feed(...slaAtRisk: ReturnType<typeof atRisk>[]) {
|
||||
return { generatedAt: "2026-09-18T11:12:00Z", sections: [], slaAtRisk };
|
||||
}
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <output aria-label="Current location">{`${location.pathname}${location.search}`}</output>;
|
||||
}
|
||||
|
||||
function renderBanner() {
|
||||
return renderWithProviders(
|
||||
<NotificationSessionProvider>
|
||||
<SlaAtRiskBanner />
|
||||
<LocationProbe />
|
||||
</NotificationSessionProvider>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
}
|
||||
|
||||
describe("SLA at-risk banner", () => {
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
toastWarn.mockReset();
|
||||
});
|
||||
|
||||
it("stays hidden while no work order is past half of its response window", async () => {
|
||||
apiGet.mockResolvedValue(feed());
|
||||
renderBanner();
|
||||
|
||||
await waitFor(() => expect(apiGet).toHaveBeenCalledWith("notifications"));
|
||||
expect(screen.queryByRole("region", { name: BANNER })).not.toBeInTheDocument();
|
||||
expect(toastWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names the single work order at risk", async () => {
|
||||
apiGet.mockResolvedValue(feed(atRisk(101)));
|
||||
renderBanner();
|
||||
|
||||
const banner = await screen.findByRole("region", { name: BANNER });
|
||||
expect(
|
||||
within(banner).getByRole("button", {
|
||||
name: "WO #1101 is at risk of missing its response deadline",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses several work orders to one line and opens Work Orders on exactly those", async () => {
|
||||
apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205), atRisk(318)));
|
||||
const user = userEvent.setup();
|
||||
renderBanner();
|
||||
|
||||
const banner = await screen.findByRole("region", { name: BANNER });
|
||||
const open = within(banner).getByRole("button", {
|
||||
name: "3 work orders are at risk of missing their response deadline",
|
||||
});
|
||||
expect(open).toHaveAttribute("title", "View in Work Orders");
|
||||
expect(within(banner).getAllByRole("button")).toHaveLength(2);
|
||||
|
||||
await user.click(open);
|
||||
|
||||
expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent(
|
||||
"/workorders?ids=101,205,318",
|
||||
);
|
||||
});
|
||||
|
||||
it("dismisses for this session only and returns when a new work order becomes at risk", async () => {
|
||||
apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205)));
|
||||
const user = userEvent.setup();
|
||||
const { queryClient, unmount } = renderBanner();
|
||||
|
||||
const banner = await screen.findByRole("region", { name: BANNER });
|
||||
await user.click(within(banner).getByRole("button", { name: "Dismiss SLA alert" }));
|
||||
expect(screen.queryByRole("region", { name: BANNER })).not.toBeInTheDocument();
|
||||
|
||||
apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205), atRisk(318)));
|
||||
await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }));
|
||||
|
||||
const returned = await screen.findByRole("region", { name: BANNER });
|
||||
expect(
|
||||
within(returned).getByRole("button", {
|
||||
name: "WO #1318 is at risk of missing its response deadline",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
renderBanner();
|
||||
expect(
|
||||
await screen.findByRole("button", {
|
||||
name: "3 work orders are at risk of missing their response deadline",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toasts each work order once per session, however often the feed polls", async () => {
|
||||
apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205, null)));
|
||||
const { queryClient } = renderBanner();
|
||||
|
||||
await screen.findByRole("region", { name: BANNER });
|
||||
expect(toastWarn).toHaveBeenCalledTimes(2);
|
||||
expect(toastWarn).toHaveBeenCalledWith("WO #1101 is at 50% of its SLA response window", {
|
||||
toastId: "sla-at-risk-101",
|
||||
});
|
||||
expect(toastWarn).toHaveBeenCalledWith("WO 205 is at 50% of its SLA response window", {
|
||||
toastId: "sla-at-risk-205",
|
||||
});
|
||||
|
||||
apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205, null), atRisk(318)));
|
||||
await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }));
|
||||
await screen.findByRole("button", {
|
||||
name: "3 work orders are at risk of missing their response deadline",
|
||||
});
|
||||
await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }));
|
||||
|
||||
expect(toastWarn).toHaveBeenCalledTimes(3);
|
||||
expect(toastWarn).toHaveBeenLastCalledWith("WO #1318 is at 50% of its SLA response window", {
|
||||
toastId: "sla-at-risk-318",
|
||||
});
|
||||
});
|
||||
});
|
||||
46
src/test/domain/auth/password-policy.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
PASSWORD_RULES,
|
||||
evaluatePasswordRules,
|
||||
meetsPasswordPolicy,
|
||||
passwordSchema,
|
||||
} from "@/domain/auth/password-policy";
|
||||
|
||||
function unmetRules(password: string): string[] {
|
||||
return evaluatePasswordRules(password)
|
||||
.filter((rule) => !rule.met)
|
||||
.map((rule) => rule.id);
|
||||
}
|
||||
|
||||
describe("password policy", () => {
|
||||
it("lists the four rules in checklist order with the shared copy", () => {
|
||||
expect(PASSWORD_RULES.map((rule) => rule.label)).toEqual([
|
||||
"At least 6 characters",
|
||||
"One uppercase letter",
|
||||
"One number",
|
||||
"One special character",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Ab1!x", "length"],
|
||||
["abc12!", "uppercase"],
|
||||
["Abcde!", "number"],
|
||||
["Abcde1", "special"],
|
||||
])("rejects %s because only the %s rule is missing", (password, missingRule) => {
|
||||
expect(unmetRules(password)).toEqual([missingRule]);
|
||||
expect(meetsPasswordPolicy(password)).toBe(false);
|
||||
expect(passwordSchema.safeParse(password).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["Abc1!x", "ABC12!"])("accepts the six-character compliant password %s", (password) => {
|
||||
expect(unmetRules(password)).toEqual([]);
|
||||
expect(meetsPasswordPolicy(password)).toBe(true);
|
||||
expect(passwordSchema.safeParse(password).success).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a non-ASCII letter as a special character, as the server does", () => {
|
||||
expect(unmetRules("Abcdé1")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
NotificationFeed,
|
||||
NotificationItem,
|
||||
} from "@/domain/notifications/types/notification-feed";
|
||||
import { withoutSlaBreach } from "@/domain/notifications/use-cases/use-acknowledge-sla-breach";
|
||||
|
||||
function row(
|
||||
overrides: Partial<NotificationItem> & Pick<NotificationItem, "id">,
|
||||
): NotificationItem {
|
||||
return {
|
||||
reason: "sla",
|
||||
severity: "Critical",
|
||||
rowType: "acknowledge",
|
||||
title: "missed its response deadline",
|
||||
count: 1,
|
||||
triggeredAt: "2026-09-18T12:00:00Z",
|
||||
target: { kind: "workOrder", workOrderId: 9, tab: "info" },
|
||||
workOrders: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const breach9 = row({ id: "sla-breach-9" });
|
||||
const breach10 = row({ id: "sla-breach-10", target: { kind: "workOrder", workOrderId: 10 } });
|
||||
const atRisk9 = row({ id: "sla-at-risk-9", severity: "High", rowType: "dismissable" });
|
||||
const aveta9 = row({ id: "aveta-missing-9", reason: "aveta-missing", rowType: "dismissable" });
|
||||
|
||||
const feed: NotificationFeed = {
|
||||
generatedAt: "2026-09-18T12:00:00Z",
|
||||
slaAtRisk: [],
|
||||
sections: [
|
||||
{
|
||||
reason: "sla",
|
||||
label: "SLA at Risk",
|
||||
severity: "Critical",
|
||||
count: 3,
|
||||
items: [breach9, breach10, atRisk9],
|
||||
},
|
||||
{
|
||||
reason: "aveta-missing",
|
||||
label: "Aveta Missing",
|
||||
severity: "Medium",
|
||||
count: 1,
|
||||
items: [aveta9],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("withoutSlaBreach", () => {
|
||||
it("drops only the acknowledged work order's breach row and lowers its section count", () => {
|
||||
const next = withoutSlaBreach(feed, 9);
|
||||
|
||||
expect(next.sections[0].items.map((item) => item.id)).toEqual([
|
||||
"sla-breach-10",
|
||||
"sla-at-risk-9",
|
||||
]);
|
||||
expect(next.sections[0].count).toBe(2);
|
||||
expect(next.sections[1]).toBe(feed.sections[1]);
|
||||
});
|
||||
|
||||
it("leaves the feed as it was when that work order has no breach row", () => {
|
||||
const next = withoutSlaBreach(feed, 42);
|
||||
|
||||
expect(next.sections).toEqual(feed.sections);
|
||||
expect(next.sections[0]).toBe(feed.sections[0]);
|
||||
});
|
||||
});
|
||||
|
|
@ -78,11 +78,14 @@ describe("servicesApi", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("does not request services for the unsupported Overdue type", async () => {
|
||||
const services = await servicesApi.getActiveForWorkOrderType("Overdue");
|
||||
it("lists the PM catalog for the Overdue type", async () => {
|
||||
vi.mocked(apiGet).mockResolvedValue([]);
|
||||
|
||||
expect(apiGet).not.toHaveBeenCalled();
|
||||
expect(services).toEqual([]);
|
||||
await servicesApi.getActiveForWorkOrderType("Overdue");
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith(API_PATHS.services.list, {
|
||||
searchParams: { isActive: true, workOrderType: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("creates, updates, and deactivates through the registry routes", async () => {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ describe("mapWizardTypeToApi", () => {
|
|||
|
||||
expect(mapWizardTypeToApi("Reactive")).toBe(WORK_ORDER_TYPE_API.Reactive);
|
||||
|
||||
expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Other);
|
||||
expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Overdue);
|
||||
|
||||
expect(WORK_ORDER_TYPE_API.Overdue).toBe(8);
|
||||
|
||||
expect(mapWizardTypeToApi("")).toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -60,16 +60,16 @@ describe("toBoardQueryParams", () => {
|
|||
expect(params.overdue).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps Overdue bar filter to overdue=true without types", () => {
|
||||
it("maps the Overdue bar filter to the Overdue type, not the past-due flag", () => {
|
||||
const params = toBoardQueryParams({ ...baseBar, types: new Set(["Overdue"]) }, "");
|
||||
expect(params.overdue).toBe(true);
|
||||
expect(params.types).toBeUndefined();
|
||||
expect(params.types).toEqual([WORK_ORDER_TYPE_API.Overdue]);
|
||||
expect(params.overdue).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps Overdue with real types as OR (types + overdue)", () => {
|
||||
it("maps Overdue with other types as a plain type union", () => {
|
||||
const params = toBoardQueryParams({ ...baseBar, types: new Set(["PM", "Overdue"]) }, "");
|
||||
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]);
|
||||
expect(params.overdue).toBe(true);
|
||||
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM, WORK_ORDER_TYPE_API.Overdue]);
|
||||
expect(params.overdue).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits types when the set is empty", () => {
|
||||
|
|
@ -222,22 +222,23 @@ describe("toBoardSearchQueryParams", () => {
|
|||
expect(buildBoardSearchParamsRecord(params)).not.toHaveProperty("unscheduledOnly");
|
||||
});
|
||||
|
||||
it("maps Overdue-only advanced filter to overdue=true without types", () => {
|
||||
it("maps the Overdue type advanced filter to types without the past-due flag", () => {
|
||||
const params = toBoardSearchQueryParams({
|
||||
filters: {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
types: new Set(["Overdue"]),
|
||||
},
|
||||
});
|
||||
expect(params.overdue).toBe(true);
|
||||
expect(params.types).toBeUndefined();
|
||||
expect(params.types).toEqual([WORK_ORDER_TYPE_API.Overdue]);
|
||||
expect(params.overdue).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps Overdue with real types as OR (types + overdue)", () => {
|
||||
it("maps the Past Due status toggle to overdue=true alongside types", () => {
|
||||
const params = toBoardSearchQueryParams({
|
||||
filters: {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
types: new Set(["PM", "Overdue"]),
|
||||
types: new Set(["PM"]),
|
||||
pastDue: true,
|
||||
},
|
||||
});
|
||||
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]);
|
||||
|
|
|
|||
|
|
@ -172,26 +172,26 @@ describe("filterByBarAndSearch", () => {
|
|||
expect(ids).toEqual(["em", "rx"]);
|
||||
});
|
||||
|
||||
it("matches Overdue via isPastDue even when row.type is not Overdue", () => {
|
||||
it("matches the Overdue type only, never a past-due row of another type", () => {
|
||||
const pastDuePm = makeRow({
|
||||
id: "pd",
|
||||
type: "PM",
|
||||
isPastDue: true,
|
||||
scheduledOn: "2026-06-03",
|
||||
});
|
||||
const currentPm = makeRow({
|
||||
id: "now",
|
||||
type: "PM",
|
||||
const overdue = makeRow({
|
||||
id: "ov",
|
||||
type: "Overdue",
|
||||
isPastDue: false,
|
||||
scheduledOn: "2026-06-03",
|
||||
});
|
||||
|
||||
const ids = filterByBarAndSearch(
|
||||
[pastDuePm, currentPm],
|
||||
[pastDuePm, overdue],
|
||||
bar(weekMonday, { types: new Set(["Overdue"]) }),
|
||||
"",
|
||||
).map((row) => row.id);
|
||||
expect(ids).toEqual(["pd"]);
|
||||
expect(ids).toEqual(["ov"]);
|
||||
});
|
||||
|
||||
it("hides a row after type correction when the bar filter no longer matches", () => {
|
||||
|
|
@ -395,13 +395,8 @@ describe("matchesAdvancedFilters", () => {
|
|||
expect(matchesAdvancedFilters(row, noMatch)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches Overdue when isPastDue is true", () => {
|
||||
it("matches the Overdue type filter by type, independent of isPastDue", () => {
|
||||
const weekMonday = mondayOf(todayIso());
|
||||
const pastDue = makeRow({
|
||||
scheduledOn: weekMonday,
|
||||
...deriveDayFields(weekMonday),
|
||||
isPastDue: true,
|
||||
});
|
||||
const filters = {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
dateRange: "custom" as const,
|
||||
|
|
@ -409,9 +404,39 @@ describe("matchesAdvancedFilters", () => {
|
|||
customTo: "9999-12-31",
|
||||
types: new Set(["Overdue" as const]),
|
||||
};
|
||||
expect(matchesAdvancedFilters(pastDue, filters)).toBe(true);
|
||||
const overdue = makeRow({ type: "Overdue", isPastDue: false, scheduledOn: weekMonday });
|
||||
const pastDuePm = makeRow({ type: "PM", isPastDue: true, scheduledOn: weekMonday });
|
||||
expect(matchesAdvancedFilters(overdue, filters)).toBe(true);
|
||||
expect(matchesAdvancedFilters(pastDuePm, filters)).toBe(false);
|
||||
});
|
||||
|
||||
it("narrows to past-due rows with the Past Due status toggle", () => {
|
||||
const weekMonday = mondayOf(todayIso());
|
||||
const filters = {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
dateRange: "custom" as const,
|
||||
customFrom: "0000-01-01",
|
||||
customTo: "9999-12-31",
|
||||
types: new Set(["PM" as const]),
|
||||
pastDue: true,
|
||||
};
|
||||
expect(
|
||||
matchesAdvancedFilters(makeRow({ isPastDue: false, scheduledOn: weekMonday }), filters),
|
||||
matchesAdvancedFilters(
|
||||
makeRow({ type: "PM", isPastDue: true, scheduledOn: weekMonday }),
|
||||
filters,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesAdvancedFilters(
|
||||
makeRow({ type: "PM", isPastDue: false, scheduledOn: weekMonday }),
|
||||
filters,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
matchesAdvancedFilters(
|
||||
makeRow({ type: "Overdue", isPastDue: true, scheduledOn: weekMonday }),
|
||||
filters,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
WORK_ORDER_IDS_MAX,
|
||||
buildWorkOrderIdsHref,
|
||||
normalizeWorkOrderIds,
|
||||
parseWorkOrderIdsParam,
|
||||
} from "@/domain/work-orders/utils/work-order-ids-filter";
|
||||
|
||||
describe("work-order ids filter", () => {
|
||||
it("builds the Work Orders link for an exact id set", () => {
|
||||
expect(buildWorkOrderIdsHref([101, 205, 318])).toBe("/workorders?ids=101,205,318");
|
||||
});
|
||||
|
||||
it("drops duplicates and non-positive ids from the link, and links the plain board when none remain", () => {
|
||||
expect(buildWorkOrderIdsHref([205, 101, 205, 0, -4, 1.5])).toBe("/workorders?ids=205,101");
|
||||
expect(buildWorkOrderIdsHref([])).toBe("/workorders");
|
||||
});
|
||||
|
||||
it("reads the ids param, ignoring malformed tokens", () => {
|
||||
expect(parseWorkOrderIdsParam(" 7, 3 ,abc,,7,-2,0,4.5,12 ")).toEqual([7, 3, 12]);
|
||||
expect(parseWorkOrderIdsParam(null)).toEqual([]);
|
||||
expect(parseWorkOrderIdsParam("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps at most the backend limit of distinct ids", () => {
|
||||
const ids = Array.from({ length: WORK_ORDER_IDS_MAX + 20 }, (_, index) => index + 1);
|
||||
const normalized = normalizeWorkOrderIds([...ids, 1, 2]);
|
||||
|
||||
expect(normalized).toHaveLength(WORK_ORDER_IDS_MAX);
|
||||
expect(normalized[0]).toBe(1);
|
||||
expect(normalized[normalized.length - 1]).toBe(WORK_ORDER_IDS_MAX);
|
||||
});
|
||||
});
|
||||