diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png index 8ee41bac..61410e8e 100644 Binary files a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png differ diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png index 0bcbb17a..c73fc9e3 100644 Binary files a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png index bcd00f31..0c695940 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png index 08602f38..0b081cba 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png index a22451fa..18302e36 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png index ad205418..a2f46763 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png index ee67ef29..1ba120c7 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png index 0e97260a..869457df 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png index da22ab30..6a2eb6e5 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png index 59dc78f3..53582910 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png index 6fab2b85..c9f0986a 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png index cd50b14e..8382b445 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png index 4e0ca158..5f3e96ae 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png index 2db0cea4..534e8552 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png index 5c41b91f..117f2d30 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png index d57d69c0..b6349d96 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png index 9707bda7..cae7003a 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png index 0e49025d..1d727318 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png differ diff --git a/src/app/(protected)/_layout.tsx b/src/app/(protected)/_layout.tsx index a5826b19..bffc9e3d 100644 --- a/src/app/(protected)/_layout.tsx +++ b/src/app/(protected)/_layout.tsx @@ -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)` }} > + + + {label} + + + {value || "—"} + + + ); +} + +export function AccountSummaryCard({ user }: { user: AuthUser }) { + return ( +
+
+ + +
+ + + Contact your Admin to change your name, role, or email. + +
+ ); +} diff --git a/src/app/(protected)/account/_components/change-password-section.tsx b/src/app/(protected)/account/_components/change-password-section.tsx new file mode 100644 index 00000000..c464f6ed --- /dev/null +++ b/src/app/(protected)/account/_components/change-password-section.tsx @@ -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({ + 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 ( + +
+
+ + + + + + + + + + + + + + + {showServerError && {serverError}} + +
+ +
+
+ ); +} diff --git a/src/app/(protected)/account/index.tsx b/src/app/(protected)/account/index.tsx new file mode 100644 index 00000000..1a645a09 --- /dev/null +++ b/src/app/(protected)/account/index.tsx @@ -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 ( + + + + {user != null && } + + + + ); +} diff --git a/src/app/(protected)/locations/_components/site-form-fields.tsx b/src/app/(protected)/locations/_components/site-form-fields.tsx index cbff09cf..1f94d7fa 100644 --- a/src/app/(protected)/locations/_components/site-form-fields.tsx +++ b/src/app/(protected)/locations/_components/site-form-fields.tsx @@ -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 ( @@ -50,18 +55,27 @@ export function SiteFormFields({ )} /> - { + // 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." + } /> { - setAdvApplied(cloneAdvancedFilters(filters)); + setAdvApplied(resolveAppliedAdvancedFilters(filters)); setPage(0); }; diff --git a/src/components/auth/password-checklist.tsx b/src/components/auth/password-checklist.tsx new file mode 100644 index 00000000..41b5e7ac --- /dev/null +++ b/src/components/auth/password-checklist.tsx @@ -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 ( + <> +
    + {results.map(({ id: ruleId, label, met }) => ( +
  • + {met ? ( +
  • + ))} +
+ + {announcement} + + + ); +} diff --git a/src/components/common/address-autocomplete-field.tsx b/src/components/common/address-autocomplete-field.tsx index 30e344c6..77b9343e 100644 --- a/src/components/common/address-autocomplete-field.tsx +++ b/src/components/common/address-autocomplete-field.tsx @@ -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 }, diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index e897bb89..92ddfcca 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -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 }:
))} + + + + ); diff --git a/src/components/notifications/notification-feed-actions.tsx b/src/components/notifications/notification-feed-actions.tsx index 3df626e2..64938205 100644 --- a/src/components/notifications/notification-feed-actions.tsx +++ b/src/components/notifications/notification-feed-actions.tsx @@ -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 ( <> diff --git a/src/components/notifications/notification-feed-list.tsx b/src/components/notifications/notification-feed-list.tsx index 48215231..701de2c1 100644 --- a/src/components/notifications/notification-feed-list.tsx +++ b/src/components/notifications/notification-feed-list.tsx @@ -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 + } /> ))} diff --git a/src/components/notifications/notification-row.tsx b/src/components/notifications/notification-row.tsx index ed254ab7..a8f1e5f0 100644 --- a/src/components/notifications/notification-row.tsx +++ b/src/components/notifications/notification-row.tsx @@ -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) { + return ( + + ); +} + /** A vendor conflict spans several work orders, so each one stays reachable from the row. */ function WorkOrderLinks({ item, onOpen }: Pick) { if (item.reason !== "vendor-conflict" || item.workOrders.length === 0) { @@ -84,7 +113,14 @@ function WorkOrderLinks({ item, onOpen }: Pick )} + {item.rowType === "acknowledge" && ( + + )} ); } diff --git a/src/components/notifications/sla-at-risk-banner.tsx b/src/components/notifications/sla-at-risk-banner.tsx new file mode 100644 index 00000000..d2f6aba1 --- /dev/null +++ b/src/components/notifications/sla-at-risk-banner.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/src/components/notifications/use-notification-center.ts b/src/components/notifications/use-notification-center.ts index a466b257..ccc6a491 100644 --- a/src/components/notifications/use-notification-center.ts +++ b/src/components/notifications/use-notification-center.ts @@ -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)), }; diff --git a/src/domain/auth/password-policy.ts b/src/domain/auth/password-policy.ts new file mode 100644 index 00000000..b1ed46f8 --- /dev/null +++ b/src/domain/auth/password-policy.ts @@ -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, +}); diff --git a/src/domain/auth/schemas/change-password-schema.ts b/src/domain/auth/schemas/change-password-schema.ts new file mode 100644 index 00000000..be3118f9 --- /dev/null +++ b/src/domain/auth/schemas/change-password-schema.ts @@ -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; diff --git a/src/domain/auth/use-cases/use-change-password.ts b/src/domain/auth/use-cases/use-change-password.ts index eec15235..c86e7860 100644 --- a/src/domain/auth/use-cases/use-change-password.ts +++ b/src/domain/auth/use-cases/use-change-password.ts @@ -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 { 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"); }, }); } diff --git a/src/domain/notifications/api/notifications-api.ts b/src/domain/notifications/api/notifications-api.ts index 034556f2..d17bd877 100644 --- a/src/domain/notifications/api/notifications-api.ts +++ b/src/domain/notifications/api/notifications-api.ts @@ -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 => notificationFeedSchema.parse(await apiGet("notifications")), + /** Records who acknowledged the missed response deadline in the work order's audit history. */ + acknowledgeSlaBreach: (workOrderId: number): Promise => + apiPostNoContent(`notifications/sla/${workOrderId}/acknowledge`), }; diff --git a/src/domain/notifications/session/notification-session-context.ts b/src/domain/notifications/session/notification-session-context.ts index c5697e97..01ddac7c 100644 --- a/src/domain/notifications/session/notification-session-context.ts +++ b/src/domain/notifications/session/notification-session-context.ts @@ -5,6 +5,11 @@ export type NotificationSession = { readIds: ReadonlySet; dismiss: (ids: string[]) => void; markRead: (ids: string[]) => void; + /** At-risk work orders hidden from the SLA banner for this session. */ + slaBannerDismissedIds: ReadonlySet; + 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(null); diff --git a/src/domain/notifications/session/notification-session-provider.tsx b/src/domain/notifications/session/notification-session-provider.tsx index 87bae800..fc389b5d 100644 --- a/src/domain/notifications/session/notification-session-provider.tsx +++ b/src/domain/notifications/session/notification-session-provider.tsx @@ -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, ids: string[]): ReadonlySet { +function addAll(current: ReadonlySet, ids: T[]): ReadonlySet { if (ids.every((id) => current.has(id))) { return current; } @@ -17,6 +17,11 @@ function addAll(current: ReadonlySet, ids: string[]): ReadonlySet>(() => new Set()); const [readIds, setReadIds] = useState>(() => new Set()); + const [slaBannerDismissedIds, setSlaBannerDismissedIds] = useState>( + () => new Set(), + ); + // A ref, not state: claiming must be synchronous so a re-run effect can never toast twice. + const toastedSlaIds = useRef(new Set()); 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 ( diff --git a/src/domain/notifications/types/notification-feed.ts b/src/domain/notifications/types/notification-feed.ts index fc5e7bdf..af242b17 100644 --- a/src/domain/notifications/types/notification-feed.ts +++ b/src/domain/notifications/types/notification-feed.ts @@ -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; @@ -60,3 +72,4 @@ export type NotificationTarget = z.infer; export type NotificationItem = z.infer; export type NotificationSection = z.infer; export type NotificationFeed = z.infer; +export type SlaAtRiskWorkOrder = z.infer; diff --git a/src/domain/notifications/use-cases/use-acknowledge-sla-breach.ts b/src/domain/notifications/use-cases/use-acknowledge-sla-breach.ts new file mode 100644 index 00000000..fb1fc62e --- /dev/null +++ b/src/domain/notifications/use-cases/use-acknowledge-sla-breach.ts @@ -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(queryKeys.notifications.feed(), (feed) => + feed ? withoutSlaBreach(feed, workOrderId) : feed, + ); + }, + onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }), + }); +} diff --git a/src/domain/notifications/utils/sla-alert-copy.ts b/src/domain/notifications/utils/sla-alert-copy.ts new file mode 100644 index 00000000..8f9e26a2 --- /dev/null +++ b/src/domain/notifications/utils/sla-alert-copy.ts @@ -0,0 +1,20 @@ +import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed"; + +function workOrderLabel(workOrder: Pick): 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[]): 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): string { + return `${workOrderLabel(workOrder)} is at 50% of its SLA response window`; +} diff --git a/src/domain/work-orders/utils/advanced-filters-utils.ts b/src/domain/work-orders/utils/advanced-filters-utils.ts index 9bcf6633..fc36045b 100644 --- a/src/domain/work-orders/utils/advanced-filters-utils.ts +++ b/src/domain/work-orders/utils/advanced-filters-utils.ts @@ -65,6 +65,18 @@ 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() }; + return next.workOrderIds.size > 0 && countAdvancedFilters(withoutIds) > 0 ? withoutIds : next; +} + function monthStart(y: number, m: number): string { return `${y}-${pad2(m + 1)}-01`; } diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index 27ef48c8..c2e34373 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -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"; @@ -135,7 +136,9 @@ function mapSearchFacetParams( /** Sent as one comma-separated `ids` value; the server then ignores every other filter. */ function mapWorkOrderIds(ids: Set | undefined): string | undefined { - return ids?.size ? [...ids].join(",") : 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. */ diff --git a/src/test/app/(protected)/account/my-account-page.test.tsx b/src/test/app/(protected)/account/my-account-page.test.tsx new file mode 100644 index 00000000..65c5ad3e --- /dev/null +++ b/src/test/app/(protected)/account/my-account-page.test.tsx @@ -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(, { + route: "/account", + queryClient: createAppQueryClient(), + }); +} + +function field(label: RegExp) { + return screen.getByLabelText(label, { selector: "input" }); +} + +async function fillPasswords( + user: ReturnType, + { 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(); + }); +}); diff --git a/src/test/app/(protected)/locations/add-site-dialog.test.tsx b/src/test/app/(protected)/locations/add-site-dialog.test.tsx index 3cef571e..a9b6c328 100644 --- a/src/test/app/(protected)/locations/add-site-dialog.test.tsx +++ b/src/test/app/(protected)/locations/add-site-dialog.test.tsx @@ -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(); diff --git a/src/test/app/(protected)/locations/site-detail-panel.test.tsx b/src/test/app/(protected)/locations/site-detail-panel.test.tsx index 0b0cf469..8b5c2885 100644 --- a/src/test/app/(protected)/locations/site-detail-panel.test.tsx +++ b/src/test/app/(protected)/locations/site-detail-panel.test.tsx @@ -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(, { 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(, { withAuth: false }); const region = await openSite("DAL1"); diff --git a/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx b/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx index 24ae3255..43a9a000 100644 --- a/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx @@ -6,8 +6,13 @@ import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_c 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 } from "@/domain/work-orders/utils/work-order-ids-filter"; +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. */ @@ -32,6 +37,15 @@ function BoardFilterHarness() { {params == null ? "weekly board" : JSON.stringify(buildBoardSearchParamsRecord(params))} {location.search} + + {filters.advApplied != null && ( { 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(, { + 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; + 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(, { + 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).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(, { route: "/workorders?ids=5&statuses=Scheduled&dispatchers=u1", diff --git a/src/test/components/auth/password-checklist.test.tsx b/src/test/components/auth/password-checklist.test.tsx new file mode 100644 index 00000000..2b7bc6dc --- /dev/null +++ b/src/test/components/auth/password-checklist.test.tsx @@ -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 ( + <> + + setPassword(event.target.value)} /> + + + ); +} + +function ruleStates(): Record { + 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(); + + 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(); + 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(); + 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(); + const input = screen.getByLabelText("Password"); + + await user.type(input, "Abcde1!"); + await user.type(input, "{Backspace}"); + + expect(ruleStates()).toMatchObject({ special: "false", length: "true", number: "true" }); + }); +}); diff --git a/src/test/components/layout/app-sidebar-nav.test.tsx b/src/test/components/layout/app-sidebar-nav.test.tsx index 07e58155..7565d3e7 100644 --- a/src/test/components/layout/app-sidebar-nav.test.tsx +++ b/src/test/components/layout/app-sidebar-nav.test.tsx @@ -26,6 +26,17 @@ function sidebarLinks(): Record { } 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 }); diff --git a/src/test/components/notifications/notification-center.test.tsx b/src/test/components/notifications/notification-center.test.tsx index 781ee098..8e169f44 100644 --- a/src/test/components/notifications/notification-center.test.tsx +++ b/src/test/components/notifications/notification-center.test.tsx @@ -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()), @@ -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(); diff --git a/src/test/components/notifications/sla-at-risk-banner.test.tsx b/src/test/components/notifications/sla-at-risk-banner.test.tsx new file mode 100644 index 00000000..163b36b9 --- /dev/null +++ b/src/test/components/notifications/sla-at-risk-banner.test.tsx @@ -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[]) { + return { generatedAt: "2026-09-18T11:12:00Z", sections: [], slaAtRisk }; +} + +function LocationProbe() { + const location = useLocation(); + return {`${location.pathname}${location.search}`}; +} + +function renderBanner() { + return renderWithProviders( + + + + , + { 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", + }); + }); +}); diff --git a/src/test/domain/auth/password-policy.test.ts b/src/test/domain/auth/password-policy.test.ts new file mode 100644 index 00000000..486f925a --- /dev/null +++ b/src/test/domain/auth/password-policy.test.ts @@ -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([]); + }); +}); diff --git a/src/test/domain/notifications/use-acknowledge-sla-breach.test.ts b/src/test/domain/notifications/use-acknowledge-sla-breach.test.ts new file mode 100644 index 00000000..5349faa8 --- /dev/null +++ b/src/test/domain/notifications/use-acknowledge-sla-breach.test.ts @@ -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 & Pick, +): 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]); + }); +});