diff --git a/docs/FEATURE_PRIORITIZATION.md b/docs/FEATURE_PRIORITIZATION.md index aa52e770..c9bd0214 100644 --- a/docs/FEATURE_PRIORITIZATION.md +++ b/docs/FEATURE_PRIORITIZATION.md @@ -26,7 +26,7 @@ | 4 | dashboard | must-have | 1 | `pages/Dashboard.js` | `/`, `/dashboard` | 253 | auth | | 5 | settings/dropdowns | must-have | 1 | `pages/settings/` (DropdownOptions) | `/settings/dropdowns` | 866\* | auth | | 6 | accounts | must-have | 2 | `pages/accounts/` | `/accounts`, `/accounts/new`, `/accounts/edit/:id` | 745 | auth | -| 7 | locations | must-have | 2 | `pages/locations/` | `/locations`, `/locations/new`, `/locations/edit/:id` | 1.1k | auth, accounts | +| 7 | locations | must-have | 2 | `pages/locations/` | `/locations` (Add Site modal, detail panel) | 1.1k | auth, accounts | | 8 | employees | must-have | 2 | `pages/employees/` | `/employees`, `/employees/new`, `/employees/edit/:id` | 1.9k | auth | | 9 | vendors | should-have | 3 | `pages/vendors/` | `/vendors`, `/vendors/new`, `/vendors/edit/:id` | 610 | auth, work-orders (full) | | 10 | vendor-pos | should-have | 3 | `pages/vendor-pos/` | `/vendor-pos`, `/vendor-pos/:id` | 1k | auth, work-orders (full) | 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 e5efa77c..2833ec24 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 304fae95..84e81461 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 81a653b5..80b51f39 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 e65a511c..8ff483ba 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 386b0b54..50f3c6df 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 797f991c..ede6fff5 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 86eca9b3..19477e67 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 8aee1f48..112e0ec4 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 3ec27339..c2ae4657 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 4a359563..61338ef2 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 6811effc..d1aa8df6 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 0a6a015c..ca02832a 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 957b6a7b..71bbcf52 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 6b87ce12..1a45825b 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 729c3f90..65075a30 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 c1569528..0b402d6e 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 d086a35f..c436a8d8 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 7e95417f..983bd92f 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/add-site-dialog.tsx b/src/app/(protected)/locations/_components/add-site-dialog.tsx new file mode 100644 index 00000000..6c73ab0e --- /dev/null +++ b/src/app/(protected)/locations/_components/add-site-dialog.tsx @@ -0,0 +1,94 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from "@mui/material"; +import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop"; +import { + locationDefaultValues, + locationSchema, + type LocationFormValues, +} from "@/domain/locations/schemas/location-schema"; +import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location"; +import { + DUPLICATE_SITE_CODE_MESSAGE, + isDuplicateSiteCodeError, +} from "@/domain/locations/utils/site-code-conflict"; +import { SiteFormFields } from "./site-form-fields"; + +interface AddSiteDialogProps { + open: boolean; + onClose: () => void; +} + +/** Add Site modal: closes on success; a duplicate Site Code stays open with an inline error. */ +export function AddSiteDialog({ open, onClose }: AddSiteDialogProps) { + const createLocation = useCreateLocation(); + const form = useForm({ + resolver: zodResolver(locationSchema), + defaultValues: locationDefaultValues, + }); + + const close = () => { + form.reset(locationDefaultValues); + createLocation.reset(); + onClose(); + }; + + const onSubmit = (values: LocationFormValues) => { + createLocation.mutate(values, { + onSuccess: close, + onError: (error) => { + if (isDuplicateSiteCodeError(error)) { + form.setError("name", { type: "server", message: DUPLICATE_SITE_CODE_MESSAGE }); + } + }, + }); + }; + + return ( + { + if (!createLocation.isPending) { + close(); + } + }} + fullWidth + maxWidth="sm" + aria-labelledby="add-site-title" + slotProps={{ backdrop: { sx: DIALOG_BLUR_BACKDROP_SX } }} + > + + + Add Site + + Create a new client site record. + + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/locations/_components/client-select.tsx b/src/app/(protected)/locations/_components/client-select.tsx new file mode 100644 index 00000000..514811ba --- /dev/null +++ b/src/app/(protected)/locations/_components/client-select.tsx @@ -0,0 +1,64 @@ +import { useMemo, useState } from "react"; +import { Controller, type Control, type FieldError } from "react-hook-form"; +import { Autocomplete, TextField } from "@mui/material"; +import type { AccountListItem } from "@/domain/accounts/types/account"; +import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list"; +import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; +import { useDebounce } from "@/hooks/use-debounce"; + +type ClientOption = Pick; + +interface ClientSelectProps { + control: Control; + error?: FieldError; + /** Stored client, shown even when it is not in the first page of search results. */ + initialClient?: ClientOption; +} + +/** Searchable client (account) picker bound to `accountId`. */ +export function ClientSelect({ control, error, initialClient }: ClientSelectProps) { + const [search, setSearch] = useState(""); + const debouncedSearch = useDebounce(search, 300); + const { data, isLoading } = useAccountsList({ search: debouncedSearch, page: 1, pageSize: 50 }); + + const options = useMemo(() => { + const items: ClientOption[] = data?.items ?? []; + const hasInitial = + initialClient !== undefined && + items.some((item) => String(item.id) === String(initialClient.id)); + return initialClient === undefined || hasInitial ? items : [initialClient, ...items]; + }, [data?.items, initialClient]); + + return ( + ( + + options={options} + loading={isLoading} + getOptionLabel={(option) => option.name || String(option.id)} + isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)} + value={options.find((option) => String(option.id) === field.value) ?? null} + onChange={(_event, option) => field.onChange(option ? String(option.id) : "")} + onBlur={field.onBlur} + onInputChange={(_event, value, reason) => { + if (reason === "input") { + setSearch(value); + } + }} + renderInput={(params) => ( + + )} + /> + )} + /> + ); +} diff --git a/src/app/(protected)/locations/_components/location-contacts-field-array.tsx b/src/app/(protected)/locations/_components/location-contacts-field-array.tsx index 9053cbe4..ed3122f0 100644 --- a/src/app/(protected)/locations/_components/location-contacts-field-array.tsx +++ b/src/app/(protected)/locations/_components/location-contacts-field-array.tsx @@ -202,7 +202,7 @@ export function LocationContactsFieldArray({ - Contacts + Points of contact diff --git a/src/app/(protected)/locations/_components/location-delete-dialog.tsx b/src/app/(protected)/locations/_components/location-delete-dialog.tsx index 0b02c348..ed87aa3c 100644 --- a/src/app/(protected)/locations/_components/location-delete-dialog.tsx +++ b/src/app/(protected)/locations/_components/location-delete-dialog.tsx @@ -23,10 +23,10 @@ export function LocationDeleteDialog({ }: LocationDeleteDialogProps) { return ( - Delete Location + Delete this site? - Are you sure you want to delete "{target?.name}"? This action cannot be undone. + This will permanently remove {target?.name} from Sites. This cannot be undone. diff --git a/src/app/(protected)/locations/_components/location-form-fields.tsx b/src/app/(protected)/locations/_components/location-form-fields.tsx deleted file mode 100644 index 39f22296..00000000 --- a/src/app/(protected)/locations/_components/location-form-fields.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { Controller, type Control, type FieldErrors, type UseFormRegister } from "react-hook-form"; -import { - Autocomplete, - FormControl, - InputLabel, - MenuItem, - Select, - Stack, - TextField, - Typography, -} from "@mui/material"; -import type { AccountListItem } from "@/domain/accounts/types/account"; -import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; -import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; -import { LocationContactsFieldArray } from "./location-contacts-field-array"; -import { StateSelect } from "./state-select"; - -interface SharedFieldsProps { - control: Control; - errors: FieldErrors; - register: UseFormRegister; -} - -interface LocationDetailsFieldsProps extends SharedFieldsProps { - accountOptions: AccountListItem[]; - isLoadingAccounts: boolean; - onAccountSearchChange: (value: string) => void; -} - -export function LocationDetailsFields({ - accountOptions, - control, - errors, - isLoadingAccounts, - onAccountSearchChange, - register, -}: LocationDetailsFieldsProps) { - return ( - - - Location Information - - - - ( - option.name || String(option.id)} - isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)} - value={accountOptions.find((account) => String(account.id) === field.value) ?? null} - onChange={(_event, option) => field.onChange(option ? String(option.id) : "")} - onInputChange={(_event, value, reason) => { - if (reason === "input") { - onAccountSearchChange(value); - } - }} - renderInput={(params) => ( - - )} - className="flex-1" - /> - )} - /> - - - - - - - - - ); -} - -export function LocationContactFields({ control, errors, register }: SharedFieldsProps) { - return ( - - - Contact Information - - - - - ( - - Status - - - )} - /> - - - ); -} diff --git a/src/app/(protected)/locations/_components/location-form.tsx b/src/app/(protected)/locations/_components/location-form.tsx deleted file mode 100644 index b410d43d..00000000 --- a/src/app/(protected)/locations/_components/location-form.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useNavigate, useParams } from "react-router"; -import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material"; -import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list"; -import { normalizeStateCode } from "@/data/areas"; -import { - locationDefaultValues, - locationSchema, - type LocationFormValues, -} from "@/domain/locations/schemas/location-schema"; -import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location"; -import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; -import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location"; -import { useDebounce } from "@/hooks/use-debounce"; -import { LocationContactFields, LocationDetailsFields } from "./location-form-fields"; - -export default function LocationFormPage() { - const { id } = useParams(); - const navigate = useNavigate(); - const isEdit = Boolean(id); - - const [accountSearch, setAccountSearch] = useState(""); - const debouncedAccountSearch = useDebounce(accountSearch, 300); - - const { data: location, isLoading, error } = useLocationDetail(id); - const { data: accountsData, isLoading: isLoadingAccounts } = useAccountsList({ - search: debouncedAccountSearch, - page: 1, - pageSize: 50, - }); - const createLocation = useCreateLocation(); - const updateLocation = useUpdateLocation(); - - const accountOptions = useMemo(() => accountsData?.items ?? [], [accountsData?.items]); - - const { - register, - handleSubmit, - reset, - control, - formState: { errors }, - } = useForm({ - resolver: zodResolver(locationSchema), - defaultValues: locationDefaultValues, - }); - - useEffect(() => { - if (location && isEdit) { - reset({ - name: location.name ?? "", - accountId: location.accountId != null ? String(location.accountId) : "", - address: location.address ?? "", - city: location.city ?? "", - state: normalizeStateCode(location.state) ?? "", - zipCode: location.zipCode ?? "", - contacts: - location.contacts && location.contacts.length > 0 - ? location.contacts - : [{ name: location.contact ?? "", phone: location.phone ?? "" }], - contactEmail: location.contactEmail ?? "", - status: (location.status as LocationFormValues["status"]) || "Active", - }); - } - }, [location, isEdit, reset]); - - const isSaving = createLocation.isPending || updateLocation.isPending; - - const onSubmit = (values: LocationFormValues) => { - if (isEdit && id) { - updateLocation.mutate({ id, values }); - return; - } - createLocation.mutate(values); - }; - - if (isEdit && isLoading) { - return ( - - - - ); - } - - return ( - - - - - - {isEdit ? "Edit Location" : "Create a Location"} - - - - - {Boolean(error) && ( - - {error instanceof Error ? error.message : "Failed to load location"} - - )} - - - - - - - - - - - - - - ); -} diff --git a/src/app/(protected)/locations/_components/locations-table.tsx b/src/app/(protected)/locations/_components/locations-table.tsx index bfd36118..840a06c0 100644 --- a/src/app/(protected)/locations/_components/locations-table.tsx +++ b/src/app/(protected)/locations/_components/locations-table.tsx @@ -1,5 +1,4 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; -import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import { Button, CircularProgress, @@ -21,83 +20,100 @@ import { StateAreaCell } from "./state-area-cell"; interface LocationsTableProps { isLoading: boolean; onDelete: (row: LocationListItem) => void; - onEdit: (row: LocationListItem) => void; + onOpen: (row: LocationListItem) => void; rows: LocationListItem[]; + selectedId?: string | null; } -export function LocationsTable({ isLoading, onDelete, onEdit, rows }: LocationsTableProps) { +const COLUMN_COUNT = 8; + +export function LocationsTable({ + isLoading, + onDelete, + onOpen, + rows, + selectedId = null, +}: LocationsTableProps) { return ( - Location Name - Address + Site Code + Client + Street Address City State - Phone - Contact - Status + POC Name + POC Phone - Actions + Actions - {isLoading ? ( + {isLoading && ( - + - ) : rows.length === 0 ? ( + )} + {!isLoading && rows.length === 0 && ( - - No locations found + + No sites found - ) : ( + )} + {!isLoading && rows.map((row) => ( - + onOpen(row)} + className="cursor-pointer" + > + {row.clientName || "—"} {row.address || "—"} {row.city || "—"} - {row.phone || "—"} - {row.status || "—"} + {row.phone || "—"} - - onEdit(row)}> - - - onDelete(row)} + aria-label={`Delete site ${row.name}`} + onClick={(event) => { + event.stopPropagation(); + onDelete(row); + }} > - )) - )} + ))}
diff --git a/src/app/(protected)/locations/_components/site-detail-edit.tsx b/src/app/(protected)/locations/_components/site-detail-edit.tsx new file mode 100644 index 00000000..00355440 --- /dev/null +++ b/src/app/(protected)/locations/_components/site-detail-edit.tsx @@ -0,0 +1,68 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Box, Button, Stack } from "@mui/material"; +import { + locationSchema, + type LocationFormValues, +} from "@/domain/locations/schemas/location-schema"; +import type { Location } from "@/domain/locations/types/location"; +import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location"; +import { SiteFormFields } from "./site-form-fields"; +import { toSiteFormValues } from "./site-form-values"; + +interface SiteDetailEditProps { + siteId: string; + location: Location; + /** Back to view mode: after Cancel (no API call) or after a successful save. */ + onDone: () => void; +} + +/** Edit mode of the site panel; Site Code is read-only, Save needs a valid, changed form. */ +export function SiteDetailEdit({ siteId, location, onDone }: SiteDetailEditProps) { + const updateLocation = useUpdateLocation(); + const form = useForm({ + resolver: zodResolver(locationSchema), + defaultValues: toSiteFormValues(location), + mode: "onChange", + }); + const { isDirty, isValid } = form.formState; + const initialClient = + location.accountId != null && location.clientName + ? { id: location.accountId, name: location.clientName } + : undefined; + + const onSubmit = (values: LocationFormValues) => { + updateLocation.mutate({ id: siteId, values }, { onSuccess: onDone }); + }; + + return ( + + + + + + + + + + ); +} diff --git a/src/app/(protected)/locations/_components/site-detail-panel.tsx b/src/app/(protected)/locations/_components/site-detail-panel.tsx new file mode 100644 index 00000000..3111bfa5 --- /dev/null +++ b/src/app/(protected)/locations/_components/site-detail-panel.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from "react"; +import { Alert, Box, Button, CircularProgress, Drawer, Stack, Typography } from "@mui/material"; +import { IconCloseButton } from "@/components/ui/icon-close-button"; +import type { Location } from "@/domain/locations/types/location"; +import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; +import { SiteDetailEdit } from "./site-detail-edit"; +import { SiteDetailView } from "./site-detail-view"; + +export const SITE_PANEL_WIDTH = 420; + +interface SiteDetailPanelProps { + /** Selected site id; `null` closes the panel. */ + siteId: string | null; + onClose: () => void; +} + +function PanelHeader({ location, onClose }: { location?: Location; onClose: () => void }) { + return ( + + + + {location?.name || "Site"} + + + {location?.clientName || " "} + + + + + ); +} + +function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void }) { + const [mode, setMode] = useState<"view" | "edit">("view"); + const { data: location, isLoading, error } = useLocationDetail(siteId); + + return ( + <> + + {isLoading && ( + + + + )} + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load site"} + + )} + {location !== undefined && mode === "edit" && ( + setMode("view")} /> + )} + {location !== undefined && mode === "view" && ( + <> + + + + + + + + )} + + ); +} + +/** + * Non-modal site panel: the table stays interactive, so clicking another row swaps the + * content in place. Escape closes it unless a popup inside already handled the key. + */ +export function SiteDetailPanel({ siteId, onClose }: SiteDetailPanelProps) { + const open = siteId !== null; + + useEffect(() => { + if (!open) { + return undefined; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && !event.defaultPrevented) { + onClose(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [open, onClose]); + + return ( + + {siteId !== null && } + + ); +} diff --git a/src/app/(protected)/locations/_components/site-detail-view.tsx b/src/app/(protected)/locations/_components/site-detail-view.tsx new file mode 100644 index 00000000..9ba956f6 --- /dev/null +++ b/src/app/(protected)/locations/_components/site-detail-view.tsx @@ -0,0 +1,76 @@ +import type { ReactNode } from "react"; +import { Link, Stack, Typography } from "@mui/material"; +import { getAreaForState } from "@/data/areas"; +import type { Location } from "@/domain/locations/types/location"; +import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps"; +import { SiteMapPreview } from "./site-map-preview"; + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( + + + {label} + + + {children} + + + ); +} + +function formatState(state?: string): string { + const area = getAreaForState(state); + const code = state?.trim() ?? ""; + if (!code) { + return "—"; + } + return area === null ? code : `${code} · Area: ${area}`; +} + +/** Read-only site details, in the prototype order. */ +export function SiteDetailView({ location }: { location: Location }) { + const contacts = location.contacts ?? []; + + return ( + + {location.name || "—"} + {location.clientName || "—"} + + {Boolean(location.address) && ( + + {location.address} + + )} + {!location.address && "—"} + + + {location.city || "—"} + {formatState(location.state)} + + + {location.sitePhone || "—"} + {location.contactEmail || "—"} + + {contacts.length === 0 && "—"} + + {contacts.map((contact, index) => ( +
  • + {index === 0 ? "Main" : `Contact ${index + 1}`}: {contact.name} + {Boolean(contact.phone) && ` · ${contact.phone}`} +
  • + ))} +
    +
    +
    + ); +} diff --git a/src/app/(protected)/locations/_components/site-form-fields.tsx b/src/app/(protected)/locations/_components/site-form-fields.tsx new file mode 100644 index 00000000..1f94d7fa --- /dev/null +++ b/src/app/(protected)/locations/_components/site-form-fields.tsx @@ -0,0 +1,111 @@ +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"; +import { LocationContactsFieldArray } from "./location-contacts-field-array"; +import { SiteMapPreview } from "./site-map-preview"; +import { StateSelect } from "./state-select"; + +interface SiteFormFieldsProps { + form: UseFormReturn; + /** Edit mode: Site Code is immutable after creation. */ + siteCodeReadOnly?: boolean; + initialClient?: Pick; +} + +/** Shared Add Site / edit-mode fields, in the prototype order. */ +export function SiteFormFields({ + form, + siteCodeReadOnly = false, + initialClient, +}: SiteFormFieldsProps) { + const { + control, + register, + 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 ( + + ( + field.onChange(event.target.value.toUpperCase())} + label="Site Code" + placeholder="e.g. AB12" + required={!siteCodeReadOnly} + disabled={siteCodeReadOnly} + error={Boolean(errors.name)} + helperText={ + errors.name?.message ?? (siteCodeReadOnly ? "Site Code can't be changed" : undefined) + } + slotProps={{ htmlInput: { maxLength: 50, readOnly: siteCodeReadOnly } }} + fullWidth + /> + )} + /> + + { + // 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" + required + requiredMarker + error={Boolean(errors.address)} + helperText={ + errors.address?.message ?? "Pick a suggestion to auto-fill City/State, or type your own." + } + /> + + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/locations/_components/site-form-values.ts b/src/app/(protected)/locations/_components/site-form-values.ts new file mode 100644 index 00000000..94cb859e --- /dev/null +++ b/src/app/(protected)/locations/_components/site-form-values.ts @@ -0,0 +1,24 @@ +import { normalizeStateCode } from "@/data/areas"; +import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; +import type { Location } from "@/domain/locations/types/location"; + +/** Edit-mode defaults from a site; zip and title are carried so a save keeps them. */ +export function toSiteFormValues(location: Location): LocationFormValues { + const contacts = + location.contacts && location.contacts.length > 0 + ? location.contacts + : [{ name: location.contact ?? "", phone: location.phone ?? "" }]; + + return { + name: location.name ?? "", + accountId: location.accountId != null ? String(location.accountId) : "", + address: location.address ?? "", + city: location.city ?? "", + state: normalizeStateCode(location.state) ?? "", + sitePhone: location.sitePhone ?? "", + contactEmail: location.contactEmail ?? "", + contacts, + zipCode: location.zipCode ?? "", + title: location.title ?? "", + }; +} diff --git a/src/app/(protected)/locations/_components/site-map-preview.tsx b/src/app/(protected)/locations/_components/site-map-preview.tsx new file mode 100644 index 00000000..4ff33a9d --- /dev/null +++ b/src/app/(protected)/locations/_components/site-map-preview.tsx @@ -0,0 +1,39 @@ +import { Box, Typography } from "@mui/material"; +import type { Location } from "@/domain/locations/types/location"; +import { buildSiteMapEmbedUrl, hasCompleteSiteAddress } from "@/domain/locations/utils/site-maps"; + +type SiteMapPreviewProps = Pick; + +/** Read-only key-less Google Maps embed; a placeholder until Street, City and State are set. */ +export function SiteMapPreview(props: SiteMapPreviewProps) { + const complete = hasCompleteSiteAddress(props); + + return ( + + + Location preview + + {complete && ( + + )} + {!complete && ( + + + Enter an address to preview the location + + + )} + + ); +} diff --git a/src/app/(protected)/locations/edit/[id].tsx b/src/app/(protected)/locations/edit/[id].tsx deleted file mode 100644 index f25ea225..00000000 --- a/src/app/(protected)/locations/edit/[id].tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../_components/location-form"; diff --git a/src/app/(protected)/locations/index.tsx b/src/app/(protected)/locations/index.tsx index 79674137..52308aad 100644 --- a/src/app/(protected)/locations/index.tsx +++ b/src/app/(protected)/locations/index.tsx @@ -1,5 +1,4 @@ -import { useMemo, useState } from "react"; -import { useNavigate } from "react-router"; +import { useCallback, useMemo, useState } from "react"; import { Alert, Box, @@ -15,20 +14,24 @@ import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-locat import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list"; import { useDebounce } from "@/hooks/use-debounce"; import { expandAreasToStateCodes, type AreaZone } from "@/data/areas"; +import { AddSiteDialog } from "./_components/add-site-dialog"; import { AreaFacet } from "./_components/area-facet"; import { LocationDeleteDialog } from "./_components/location-delete-dialog"; import { LocationsTable } from "./_components/locations-table"; +import { SITE_PANEL_WIDTH, SiteDetailPanel } from "./_components/site-detail-panel"; export default function LocationsListPage() { - const navigate = useNavigate(); const [search, setSearch] = useState(""); const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(12); const [selectedAreas, setSelectedAreas] = useState([]); const [deleteTarget, setDeleteTarget] = useState(null); + const [isAddOpen, setIsAddOpen] = useState(false); + const [selectedId, setSelectedId] = useState(null); const debouncedSearch = useDebounce(search, 300); const deleteLocation = useDeleteLocation(); + const closePanel = useCallback(() => setSelectedId(null), []); const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]); @@ -52,13 +55,20 @@ export default function LocationsListPage() { return; } + const deletedId = String(deleteTarget.id); deleteLocation.mutate(deleteTarget.id, { - onSuccess: () => setDeleteTarget(null), + onSuccess: () => { + setDeleteTarget(null); + setSelectedId((current) => (current === deletedId ? null : current)); + }, }); }; return ( - + - + - Locations + Sites - + {totalCount} total {isFetching && !isLoading && } - + { @@ -109,23 +102,24 @@ export default function LocationsListPage() { setSearch(event.target.value); setPage(0); }} - placeholder="Search locations..." + placeholder="Search sites..." className="min-w-[220px]" /> - {Boolean(error) && ( - {error instanceof Error ? error.message : "Failed to load locations"} + {error instanceof Error ? error.message : "Failed to load sites"} )} navigate(`/locations/edit/${row.id}`)} + selectedId={selectedId} + onOpen={(row) => setSelectedId(String(row.id))} onDelete={setDeleteTarget} /> + setIsAddOpen(false)} /> + { - 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/locations/mappers/location-mapper.ts b/src/domain/locations/mappers/location-mapper.ts index 6713c559..99241eb0 100644 --- a/src/domain/locations/mappers/location-mapper.ts +++ b/src/domain/locations/mappers/location-mapper.ts @@ -18,6 +18,14 @@ function readString(record: Record, ...keys: string[]): string return ""; } +/** Like readString, but an empty value becomes undefined. */ +function readOptionalString( + record: Record, + ...keys: string[] +): string | undefined { + return readString(record, ...keys) || undefined; +} + function readId(record: Record): string | number { const value = record.id ?? record.Id; if (typeof value === "string" || typeof value === "number") { @@ -123,6 +131,8 @@ export function mapLocationListItem(raw: unknown): LocationListItem { return { id: readId(item), name: readString(item, "name", "Name"), + clientName: readString(item, "clientName", "ClientName", "accountName", "AccountName"), + sitePhone: readString(item, "phone", "Phone"), address: readString(item, "address", "Address"), city: readString(item, "city", "City"), state: readString(item, "state", "State"), @@ -154,21 +164,30 @@ export function mapLocation(raw: unknown): Location { const contacts = mapLocationContacts(item.contacts ?? item.Contacts); const mainContact = contacts[0]; - const legacyPhone = readString(item, "phone", "Phone") || undefined; - const legacyContact = - readString(item, "contact", "Contact", "contactPerson", "ContactPerson") || undefined; + const legacyPhone = readOptionalString(item, "phone", "Phone"); + const legacyContact = readOptionalString( + item, + "contact", + "Contact", + "contactPerson", + "ContactPerson", + ); return { id: readId(item) || undefined, - name: readString(item, "name", "Name") || undefined, - address: address || undefined, - city: readString(item, "city", "City") || undefined, - state: readString(item, "state", "State") || undefined, - zipCode: readString(item, "zipCode", "ZipCode", "Zip") || undefined, + name: readOptionalString(item, "name", "Name"), + title: readOptionalString(item, "title", "Title"), + clientName: readOptionalString(item, "clientName", "ClientName", "accountName", "AccountName"), + sitePhone: legacyPhone, + notes: readOptionalString(item, "notes", "Notes"), + address, + city: readOptionalString(item, "city", "City"), + state: readOptionalString(item, "state", "State"), + zipCode: readOptionalString(item, "zipCode", "ZipCode", "Zip"), phone: mainContact ? mainContact.phone : legacyPhone, contact: mainContact ? mainContact.name : legacyContact, - contactEmail: readString(item, "contactEmail", "ContactEmail") || undefined, - status: readString(item, "status", "Status") || undefined, + contactEmail: readOptionalString(item, "contactEmail", "ContactEmail"), + status: readOptionalString(item, "status", "Status"), accountId: readOptionalId(item) || undefined, contacts: contacts.length > 0 ? contacts : undefined, }; @@ -196,6 +215,10 @@ export function mapContactsToBackend(raw: unknown): LocationContact[] { .filter((contact) => contact.name !== "" || contact.phone !== ""); } +/** + * Create/update payload. `phone` is the Site Phone (independent of the contacts); `status`, + * `title` and `notes` are sent only when present so an update keeps the stored values. + */ export function mapLocationToBackend(location: unknown): Record { const item = (typeof location === "object" && location !== null ? location : {}) as Record< string, @@ -206,19 +229,25 @@ export function mapLocationToBackend(location: unknown): Record const mainContact = contacts[0]; const payload: Record = { - name: item.name ?? "", + name: typeof item.name === "string" ? item.name.trim() : "", address: item.address ?? "", city: item.city ?? "", state: item.state ?? "", zipCode: item.zipCode ?? "", - phone: mainContact ? mainContact.phone : (item.phone ?? ""), - contact: mainContact ? mainContact.name : (item.contact ?? ""), - contactEmail: item.contactEmail ?? "", - status: item.status ?? "Active", + phone: typeof item.sitePhone === "string" ? item.sitePhone.trim() : "", + // A caller without a contacts list (e.g. address-only sync) keeps the stored legacy name. + contact: mainContact ? mainContact.name : readString(item, "contact").trim(), + contactEmail: typeof item.contactEmail === "string" ? item.contactEmail.trim() : "", accountId: item.accountId ?? "", contacts, }; + for (const key of ["status", "title", "notes"] as const) { + if (typeof item[key] === "string") { + payload[key] = item[key]; + } + } + const id = item.id ?? item.Id; if (typeof id === "string" || typeof id === "number") { payload.id = id; diff --git a/src/domain/locations/schemas/location-schema.ts b/src/domain/locations/schemas/location-schema.ts index 7c9e878c..a45b7b90 100644 --- a/src/domain/locations/schemas/location-schema.ts +++ b/src/domain/locations/schemas/location-schema.ts @@ -1,29 +1,43 @@ import { z } from "zod"; import { US_STATE_CODES } from "@/data/areas"; -import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; export const locationContactSchema = z.object({ id: z.number().int().positive().optional(), name: z .string() .trim() - .min(1, "Contact person is required") - .max(100, "Contact person cannot exceed 100 characters"), - phone: z.string().trim().min(1, "Phone is required").max(20, "Phone cannot exceed 20 characters"), + .min(1, "Contact name is required") + .max(100, "Contact name cannot exceed 100 characters"), + phone: z + .string() + .trim() + .min(1, "Contact phone is required") + .max(20, "Contact phone cannot exceed 20 characters"), }); +/** Add Site / Site detail edit form. `zipCode` and `title` are carried through, not edited. */ export const locationSchema = z.object({ - name: z.string().min(1, "Name is required"), - accountId: z.string().optional(), - address: z.string().min(1, "Address is required"), - city: z.string().min(1, "City is required"), + name: z + .string() + .trim() + .min(1, "Site Code is required") + .max(50, "Site Code cannot exceed 50 characters"), + accountId: z.string().min(1, "Client is required"), + address: z.string().trim().min(1, "Street Address is required"), + city: z.string().trim().min(1, "City is required"), state: z .union([z.enum(US_STATE_CODES), z.literal("")]) .refine((state): boolean => state !== "", { message: "State is required" }), - zipCode: z.string().min(1, "Zip code is required"), + sitePhone: z.string().trim().max(20, "Site Phone cannot exceed 20 characters"), + contactEmail: z + .string() + .trim() + .refine((value) => value === "" || z.email().safeParse(value).success, { + message: "Invalid email address", + }), contacts: z.array(locationContactSchema).min(1, "At least one contact is required"), - contactEmail: z.string().min(1, "Contact email is required").email("Invalid email address"), - status: z.enum(LOCATION_STATUS_OPTIONS, { message: "Status is required" }), + zipCode: z.string(), + title: z.string(), }); export type LocationFormValues = z.infer; @@ -35,8 +49,9 @@ export const locationDefaultValues: LocationFormValues = { address: "", city: "", state: "", - zipCode: "", - contacts: [{ name: "", phone: "" }], + sitePhone: "", contactEmail: "", - status: "Active", + contacts: [{ name: "", phone: "" }], + zipCode: "", + title: "", }; diff --git a/src/domain/locations/types/location.ts b/src/domain/locations/types/location.ts index 7fa99625..77cadb17 100644 --- a/src/domain/locations/types/location.ts +++ b/src/domain/locations/types/location.ts @@ -10,6 +10,10 @@ export interface LocationContact { export interface LocationListItem { id: string | number; name: string; + /** Client (account) display name. */ + clientName: string; + /** Site Phone, independent of the contacts. */ + sitePhone: string; address: string; city: string; state: string; @@ -26,6 +30,10 @@ export interface LocationListItem { export interface Location { id?: string | number; name?: string; + title?: string; + clientName?: string; + sitePhone?: string; + notes?: string; address?: string; city?: string; state?: string; @@ -61,5 +69,3 @@ export interface LocationsListResult { page: number; pageSize: number; } - -export const LOCATION_STATUS_OPTIONS = ["Active", "Inactive", "Closed"] as const; diff --git a/src/domain/locations/use-cases/use-create-location.ts b/src/domain/locations/use-cases/use-create-location.ts index 421de42c..22bbd202 100644 --- a/src/domain/locations/use-cases/use-create-location.ts +++ b/src/domain/locations/use-cases/use-create-location.ts @@ -1,24 +1,27 @@ import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; -import { useNavigate } from "react-router"; import { toast } from "react-toastify"; import { locationsApi } from "@/domain/locations/api/locations-api"; import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; -import type { Location } from "@/domain/locations/types/location"; +import { isDuplicateSiteCodeError } from "@/domain/locations/utils/site-code-conflict"; import { queryKeys } from "@/infra/query-key/query-key"; -export function useCreateLocation(): UseMutationResult { - const navigate = useNavigate(); +/** Creates a site. A duplicate Site Code is left to the caller to show inline (no toast). */ +export function useCreateLocation(): UseMutationResult { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (values: LocationFormValues) => locationsApi.create(values), + mutationFn: async (values: LocationFormValues) => { + await locationsApi.create({ ...values, status: "Active" }); + }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }); - toast.success("Location created successfully!"); - navigate("/locations"); + toast.success("Site added"); }, onError: (error: Error) => { - toast.error(error.message || "Failed to create location"); + if (isDuplicateSiteCodeError(error)) { + return; + } + toast.error(error.message || "Failed to add site"); }, }); } diff --git a/src/domain/locations/use-cases/use-delete-location.ts b/src/domain/locations/use-cases/use-delete-location.ts index 4e710337..598b6126 100644 --- a/src/domain/locations/use-cases/use-delete-location.ts +++ b/src/domain/locations/use-cases/use-delete-location.ts @@ -10,10 +10,10 @@ export function useDeleteLocation(): UseMutationResult locationsApi.delete(id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }); - toast.success("Location deleted successfully!"); + toast.success("Site deleted"); }, onError: (error: Error) => { - toast.error(error.message || "Failed to delete location"); + toast.error(error.message || "Failed to delete site"); }, }); } diff --git a/src/domain/locations/use-cases/use-update-location.ts b/src/domain/locations/use-cases/use-update-location.ts index 02e271c6..05d8b874 100644 --- a/src/domain/locations/use-cases/use-update-location.ts +++ b/src/domain/locations/use-cases/use-update-location.ts @@ -1,9 +1,7 @@ import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; -import { useNavigate } from "react-router"; import { toast } from "react-toastify"; import { locationsApi } from "@/domain/locations/api/locations-api"; import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; -import type { Location } from "@/domain/locations/types/location"; import { queryKeys } from "@/infra/query-key/query-key"; interface UpdateLocationInput { @@ -11,20 +9,25 @@ interface UpdateLocationInput { values: LocationFormValues; } -export function useUpdateLocation(): UseMutationResult { - const navigate = useNavigate(); +/** Saves a site. The response carries no site, so the list and detail are refetched. */ +export function useUpdateLocation(): UseMutationResult { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, values }: UpdateLocationInput) => locationsApi.update(id, values), - onSuccess: (_data, variables) => { - void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }); - void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(variables.id) }); - toast.success("Location updated successfully!"); - navigate("/locations"); + mutationFn: async ({ id, values }: UpdateLocationInput) => { + await locationsApi.update(id, values); + }, + onSuccess: async (_data, variables) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }), + queryClient.invalidateQueries({ + queryKey: queryKeys.locations.detail(String(variables.id)), + }), + ]); + toast.success("Site updated"); }, onError: (error: Error) => { - toast.error(error.message || "Failed to update location"); + toast.error(error.message || "Failed to update site"); }, }); } diff --git a/src/domain/locations/utils/site-code-conflict.ts b/src/domain/locations/utils/site-code-conflict.ts new file mode 100644 index 00000000..5c2e8e2a --- /dev/null +++ b/src/domain/locations/utils/site-code-conflict.ts @@ -0,0 +1,14 @@ +import { isHTTPError } from "ky"; + +export const DUPLICATE_SITE_CODE_MESSAGE = "This site code already exists."; + +/** The API answers a duplicate Site Code (same client, any case) with 409 DuplicateSiteCode. */ +export function isDuplicateSiteCodeError(error: unknown): boolean { + if (!isHTTPError(error)) { + return false; + } + const data = error.data; + const code = + data && typeof data === "object" ? (data as Record).code : undefined; + return error.response.status === 409 || code === "DuplicateSiteCode"; +} diff --git a/src/domain/locations/utils/site-maps.ts b/src/domain/locations/utils/site-maps.ts new file mode 100644 index 00000000..28a75792 --- /dev/null +++ b/src/domain/locations/utils/site-maps.ts @@ -0,0 +1,23 @@ +import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; +import type { Location } from "@/domain/locations/types/location"; + +type SiteAddress = Pick; + +/** Google Maps search link for a site address; opens in a new tab. */ +export function buildSiteMapsUrl(location: SiteAddress): string { + const query = formatLocationAddressPreview(location); + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; +} + +/** True once Street, City and State are all filled, so a map preview can render. */ +export function hasCompleteSiteAddress(location: SiteAddress): boolean { + return [location.address, location.city, location.state].every( + (part) => (part?.trim() ?? "") !== "", + ); +} + +/** Key-less Google Maps embed URL for the read-only preview. */ +export function buildSiteMapEmbedUrl(location: SiteAddress): string { + const query = formatLocationAddressPreview(location); + return `https://www.google.com/maps?q=${encodeURIComponent(query)}&output=embed`; +} 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 new file mode 100644 index 00000000..a9b6c328 --- /dev/null +++ b/src/test/app/(protected)/locations/add-site-dialog.test.tsx @@ -0,0 +1,207 @@ +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(); + +vi.mock("@/domain/locations/api/locations-api", () => ({ + locationsApi: { create: (...args: unknown[]) => create(...args) }, +})); + +vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({ + useAccountsList: () => ({ + data: { items: [{ id: 7, name: "Acme Logistics" }], totalCount: 1, page: 1, pageSize: 50 }, + isLoading: false, + }), +})); + +vi.mock("@/hooks/use-debounce", () => ({ + useDebounce: (value: string) => value, +})); + +function duplicateCodeError(): HTTPError { + const body = { + status: "Conflict", + message: "This site code already exists.", + code: "DuplicateSiteCode", + }; + const error = new HTTPError( + new Response(JSON.stringify(body), { status: 409 }), + new Request("https://api.example.test/api/locations"), + {} as never, + ); + error.data = body; + return normalizeApiRequestError(error) as HTTPError; +} + +function renderDialog() { + const onClose = vi.fn(); + renderWithProviders(, { withAuth: false }); + return { onClose, dialog: screen.getByRole("dialog", { name: /add site/i }) }; +} + +async function pickOption(label: string, optionName: string, search: string) { + const input = screen.getByRole("combobox", { name: label }); + input.focus(); + fireEvent.mouseDown(input); + fireEvent.change(input, { target: { value: search } }); + fireEvent.click(await screen.findByRole("option", { name: optionName })); +} + +async function fillRequiredFields(dialog: HTMLElement) { + fireEvent.change(within(dialog).getByRole("textbox", { name: "Site Code" }), { + target: { value: "dal1" }, + }); + await pickOption("Client", "Acme Logistics", "Acme"); + fireEvent.change(within(dialog).getByRole("combobox", { name: "Street Address" }), { + target: { value: "3811 Distribution Dr" }, + }); + fireEvent.change(within(dialog).getByRole("textbox", { name: "City" }), { + target: { value: "Dallas" }, + }); + await pickOption("State", "Texas (TX)", "texas"); + fireEvent.change(within(dialog).getByLabelText(/^Contact Name \(Main\)/), { + target: { value: "Jane" }, + }); + fireEvent.change(within(dialog).getByLabelText(/^Phone/), { + target: { value: "(421) 433-0032" }, + }); +} + +describe("AddSiteDialog", () => { + beforeEach(() => { + create.mockReset(); + }); + + it("blocks submission and names every missing required field", async () => { + const { dialog } = renderDialog(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Add Site" })); + + for (const message of [ + "Site Code is required", + "Client is required", + "Street Address is required", + "City is required", + "State is required", + "Contact name is required", + "Contact phone is required", + ]) { + expect(await within(dialog).findByText(message)).toBeInTheDocument(); + } + expect(create).not.toHaveBeenCalled(); + }); + + it("shows the map placeholder until Street, City and State are filled, then the preview", async () => { + const { dialog } = renderDialog(); + + expect( + within(dialog).getByText("Enter an address to preview the location"), + ).toBeInTheDocument(); + await fillRequiredFields(dialog); + + const map = within(dialog).getByTitle("Site location map"); + expect(map).toHaveAttribute( + "src", + "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX&output=embed", + ); + 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(); + + await fillRequiredFields(dialog); + fireEvent.click(within(dialog).getByRole("button", { name: "Add Site" })); + + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + name: "DAL1", + accountId: "7", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + sitePhone: "", + contactEmail: "", + status: "Active", + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], + }), + ); + }); + + it("keeps the modal open with an inline error when the Site Code already exists", async () => { + create.mockRejectedValue(duplicateCodeError()); + const toastError = vi.spyOn(toast, "error"); + const { dialog, onClose } = renderDialog(); + + await fillRequiredFields(dialog); + fireEvent.click(within(dialog).getByRole("button", { name: "Add Site" })); + + expect(await within(dialog).findByText("This site code already exists.")).toBeInTheDocument(); + expect(within(dialog).getByRole("textbox", { name: "Site Code" })).toHaveAttribute( + "aria-invalid", + "true", + ); + expect(onClose).not.toHaveBeenCalled(); + expect(toastError).not.toHaveBeenCalled(); + }); + + it("Cancel closes without calling the API", async () => { + const { dialog, onClose } = renderDialog(); + + await fillRequiredFields(dialog); + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx b/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx index 60444e29..fa6df17e 100644 --- a/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx +++ b/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx @@ -37,6 +37,7 @@ function ContactsHarness({ state: "TX", zipCode: "70068", contactEmail: "jane@example.com", + accountId: "7", contacts: initialContacts, }, }); @@ -78,7 +79,7 @@ describe("LocationContactsFieldArray", () => { { withAuth: false }, ); - fireEvent.click(screen.getByRole("button", { name: /add contact/i })); + fireEvent.click(screen.getByRole("button", { name: /add point of contact/i })); expect(nameInputs()).toHaveLength(2); expect(screen.getByLabelText("Remove contact 1")).toBeEnabled(); diff --git a/src/test/app/(protected)/locations/location-form-state.test.tsx b/src/test/app/(protected)/locations/location-form-state.test.tsx deleted file mode 100644 index 23b716a3..00000000 --- a/src/test/app/(protected)/locations/location-form-state.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { screen } from "@testing-library/react"; -import { Route, Routes } from "react-router"; -import { describe, expect, it, vi } from "vitest"; -import LocationFormPage from "@/app/(protected)/locations/_components/location-form"; -import { renderWithProviders } from "@/test/test-utils"; - -const useLocationDetail = vi.fn(); -const useAccountsList = vi.fn(); -const useCreateLocation = vi.fn(); -const useUpdateLocation = vi.fn(); - -vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({ - useLocationDetail: (...args: unknown[]) => useLocationDetail(...args), -})); - -vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({ - useAccountsList: (...args: unknown[]) => useAccountsList(...args), -})); - -vi.mock("@/domain/locations/use-cases/use-create-location", () => ({ - useCreateLocation: (...args: unknown[]) => useCreateLocation(...args), -})); - -vi.mock("@/domain/locations/use-cases/use-update-location", () => ({ - useUpdateLocation: (...args: unknown[]) => useUpdateLocation(...args), -})); - -function mockHooks(location: Record | null) { - useLocationDetail.mockReturnValue({ - data: location, - isLoading: false, - error: null, - }); - useAccountsList.mockReturnValue({ - data: { items: [], totalCount: 0, page: 1, pageSize: 50 }, - isLoading: false, - }); - useCreateLocation.mockReturnValue({ mutate: vi.fn(), isPending: false }); - useUpdateLocation.mockReturnValue({ mutate: vi.fn(), isPending: false }); -} - -function renderEditPage(locationId: string) { - return renderWithProviders( - - } /> - , - { - route: `/locations/edit/${locationId}`, - routerProps: { initialEntries: [`/locations/edit/${locationId}`] }, - withAuth: false, - }, - ); -} - -describe("LocationFormPage state field", () => { - it("normalizes legacy API state values for edit", () => { - mockHooks({ - id: "1", - name: "Dallas DC", - address: "3811 Distribution Dr", - city: "Dallas", - state: "texas", - zipCode: "70068", - phone: "(421) 433-0032", - contact: "Jane", - contactEmail: "jane@example.com", - status: "Active", - accountId: "7", - }); - - renderEditPage("1"); - - const stateInput = screen.getByLabelText(/^State/); - expect(stateInput).toHaveValue("Texas (TX)"); - expect(screen.getByText("Area: West")).toBeInTheDocument(); - }); - - it("normalizes code values and keeps their derived area", () => { - mockHooks({ - id: "2", - state: "IL", - status: "Active", - }); - - renderEditPage("2"); - - expect(screen.getByLabelText(/^State/)).toHaveValue("Illinois (IL)"); - expect(screen.getByText("Area: Central")).toBeInTheDocument(); - }); - - it("starts empty in create mode with no implicit IL default", () => { - mockHooks(null); - - renderWithProviders(, { - route: "/locations/new", - routerProps: { initialEntries: ["/locations/new"] }, - withAuth: false, - }); - - expect(screen.getByLabelText(/^State/)).toHaveValue(""); - expect(screen.queryByText(/Area: /)).not.toBeInTheDocument(); - }); -}); diff --git a/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx b/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx index e0258626..a903447f 100644 --- a/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx +++ b/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx @@ -8,6 +8,8 @@ function rowWithContacts(overrides: Partial): LocationListItem return { id: 1, name: "Dallas DC", + clientName: "Acme", + sitePhone: "", address: "3811 Distribution Dr", city: "Dallas", state: "TX", diff --git a/src/test/app/(protected)/locations/locations-list-area.test.tsx b/src/test/app/(protected)/locations/locations-list-area.test.tsx index fb45e28c..19b54ad8 100644 --- a/src/test/app/(protected)/locations/locations-list-area.test.tsx +++ b/src/test/app/(protected)/locations/locations-list-area.test.tsx @@ -125,7 +125,7 @@ describe("LocationsListPage area wiring", () => { fireEvent.mouseDown(screen.getByLabelText("Area")); fireEvent.click(screen.getByRole("option", { name: "West" })); - fireEvent.change(screen.getByPlaceholderText("Search locations..."), { + fireEvent.change(screen.getByPlaceholderText("Search sites..."), { target: { value: "dal" }, }); diff --git a/src/test/app/(protected)/locations/site-detail-panel.test.tsx b/src/test/app/(protected)/locations/site-detail-panel.test.tsx new file mode 100644 index 00000000..27e0edc4 --- /dev/null +++ b/src/test/app/(protected)/locations/site-detail-panel.test.tsx @@ -0,0 +1,251 @@ +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(); +const getById = vi.fn(); +const update = vi.fn(); + +vi.mock("@/domain/locations/api/locations-api", () => ({ + locationsApi: { + getList: (...args: unknown[]) => getList(...args), + getById: (...args: unknown[]) => getById(...args), + update: (...args: unknown[]) => update(...args), + create: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({ + useAccountsList: () => ({ + data: { items: [], totalCount: 0, page: 1, pageSize: 50 }, + isLoading: false, + }), +})); + +vi.mock("@/hooks/use-debounce", () => ({ + useDebounce: (value: string) => value, +})); + +const dallas: Location = { + id: 1, + name: "DAL1", + clientName: "Acme Logistics", + accountId: 7, + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "75201", + sitePhone: "(214) 555-0100", + contactEmail: "dal1@acme.test", + contacts: [ + { id: 31, name: "Jane", phone: "(421) 433-0032" }, + { id: 32, name: "Bob", phone: "(421) 433-0033" }, + ], +}; + +const chicago: Location = { + id: 2, + name: "CHI2", + clientName: "Globex", + accountId: 8, + address: "100 Main St", + city: "Chicago", + state: "IL", + contacts: [{ id: 41, name: "Carol", phone: "(312) 555-0100" }], +}; + +function listRow(location: Location): LocationListItem { + return { + id: location.id ?? "", + name: location.name ?? "", + clientName: location.clientName ?? "", + sitePhone: location.sitePhone ?? "", + address: location.address ?? "", + city: location.city ?? "", + state: location.state ?? "", + zipCode: location.zipCode ?? "", + phone: location.contacts?.[0]?.phone ?? "", + contact: location.contacts?.[0]?.name ?? "", + contactEmail: location.contactEmail ?? "", + status: "Active", + accountId: location.accountId ?? "", + contacts: location.contacts, + }; +} + +function panel(): HTMLElement { + return screen.getByRole("region", { name: "Site details" }); +} + +async function openSite(code: string): Promise { + fireEvent.click(await screen.findByRole("button", { name: code })); + const region = panel(); + await within(region).findByRole("heading", { name: code }); + return region; +} + +describe("Sites page detail panel", () => { + beforeEach(() => { + getList.mockReset(); + getById.mockReset(); + update.mockReset(); + getList.mockResolvedValue({ + items: [listRow(dallas), listRow(chicago)], + totalCount: 2, + page: 1, + pageSize: 12, + }); + getById.mockImplementation((id: string) => + Promise.resolve(String(id) === "2" ? chicago : dallas), + ); + }); + + it("opens a row in view mode with the address linked to Google Maps and a map preview", async () => { + renderWithProviders(, { withAuth: false }); + + const region = await openSite("DAL1"); + + expect(within(region).getAllByText("Acme Logistics").length).toBeGreaterThan(0); + const address = within(region).getByRole("link", { name: "3811 Distribution Dr" }); + expect(address).toHaveAttribute( + "href", + "https://www.google.com/maps/search/?api=1&query=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201", + ); + expect(address).toHaveAttribute("target", "_blank"); + expect(within(region).getByTitle("Site location map")).toBeInTheDocument(); + expect(within(region).getByText("TX · Area: West")).toBeInTheDocument(); + expect(within(region).getByText("(214) 555-0100")).toBeInTheDocument(); + expect(within(region).getByText("dal1@acme.test")).toBeInTheDocument(); + expect(within(region).getByText(/Main: Jane/)).toBeInTheDocument(); + expect(within(region).getByText(/Contact 2: Bob/)).toBeInTheDocument(); + expect(within(region).queryByText(/status/i)).not.toBeInTheDocument(); + }); + + it("switches to edit in place, saves, and returns to view showing the saved values", async () => { + update.mockResolvedValue({}); + renderWithProviders(, { withAuth: false }); + const region = await openSite("DAL1"); + + fireEvent.click(within(region).getByRole("button", { name: "Edit" })); + + const siteCode = within(region).getByRole("textbox", { name: "Site Code" }); + expect(siteCode).toHaveValue("DAL1"); + expect(siteCode).toBeDisabled(); + const save = within(region).getByRole("button", { name: "Save changes" }); + expect(save).toBeDisabled(); + + getById.mockResolvedValue({ ...dallas, city: "Fort Worth" }); + fireEvent.change(within(region).getByRole("textbox", { name: "City" }), { + target: { value: "Fort Worth" }, + }); + await waitFor(() => expect(save).toBeEnabled()); + fireEvent.click(save); + + await waitFor(() => expect(update).toHaveBeenCalledTimes(1)); + expect(update).toHaveBeenCalledWith( + "1", + expect.objectContaining({ + name: "DAL1", + city: "Fort Worth", + sitePhone: "(214) 555-0100", + zipCode: "75201", + contacts: [ + { id: 31, name: "Jane", phone: "(421) 433-0032" }, + { id: 32, name: "Bob", phone: "(421) 433-0033" }, + ], + }), + ); + expect(await within(region).findByRole("button", { name: "Edit" })).toBeInTheDocument(); + 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"); + + fireEvent.click(within(region).getByRole("button", { name: "Edit" })); + fireEvent.change(within(region).getByRole("textbox", { name: "City" }), { + target: { value: "Houston" }, + }); + fireEvent.click(within(region).getByRole("button", { name: "Cancel" })); + + expect(within(region).getByRole("button", { name: "Edit" })).toBeInTheDocument(); + expect(within(region).getByText("Dallas")).toBeInTheDocument(); + expect(within(region).queryByText("Houston")).not.toBeInTheDocument(); + expect(update).not.toHaveBeenCalled(); + }); + + it("keeps the table usable: another row swaps the panel content in place", async () => { + renderWithProviders(, { withAuth: false }); + const region = await openSite("DAL1"); + fireEvent.click(within(region).getByRole("button", { name: "Edit" })); + + fireEvent.click(screen.getByRole("button", { name: "CHI2" })); + + await within(panel()).findByRole("heading", { name: "CHI2" }); + expect(panel()).toBe(region); + expect(within(region).getByRole("button", { name: "Edit" })).toBeInTheDocument(); + expect(within(region).getAllByText("Globex").length).toBeGreaterThan(0); + expect(within(region).queryByText("Acme Logistics")).not.toBeInTheDocument(); + }); + + it("Escape closes the panel", async () => { + renderWithProviders(, { withAuth: false }); + await openSite("DAL1"); + + fireEvent.keyDown(document, { key: "Escape" }); + + await waitFor(() => + expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument(), + ); + }); + it("row delete asks to confirm removing the site without opening the panel", async () => { + renderWithProviders(, { withAuth: false }); + + fireEvent.click(await screen.findByRole("button", { name: "Delete site DAL1" })); + + const confirm = await screen.findByRole("dialog", { name: "Delete this site?" }); + expect( + within(confirm).getByText( + "This will permanently remove DAL1 from Sites. This cannot be undone.", + ), + ).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/locations/state-select.test.tsx b/src/test/app/(protected)/locations/state-select.test.tsx index 68c3fa51..7ebf6040 100644 --- a/src/test/app/(protected)/locations/state-select.test.tsx +++ b/src/test/app/(protected)/locations/state-select.test.tsx @@ -29,7 +29,7 @@ function StateSelectHarness({ zipCode: "70068", contacts: [{ name: "Jane", phone: "(421) 433-0032" }], contactEmail: "jane@example.com", - status: "Active", + accountId: "7", }, }); 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/locations/mappers/location-mapper.test.ts b/src/test/domain/locations/mappers/location-mapper.test.ts index 52d27cc6..62ce35d1 100644 --- a/src/test/domain/locations/mappers/location-mapper.test.ts +++ b/src/test/domain/locations/mappers/location-mapper.test.ts @@ -56,16 +56,15 @@ describe("location contacts hydration", () => { }); }); -describe("mapLocationToBackend contacts payload", () => { - it("sends trimmed contacts in displayed order and derives legacy fields from the first", () => { +describe("mapLocationToBackend site payload", () => { + it("sends trimmed contacts in displayed order and names the first as the legacy contact", () => { const payload = mapLocationToBackend({ - name: "Dallas DC", + name: " dal1 ", address: "3811 Distribution Dr", city: "Dallas", state: "TX", zipCode: "70068", contactEmail: "jane@example.com", - status: "Active", contacts: [ { id: 41, name: " Jane ", phone: " (421) 433-0032 " }, { name: "Bob", phone: "(421) 433-0033" }, @@ -77,18 +76,74 @@ describe("mapLocationToBackend contacts payload", () => { { name: "Bob", phone: "(421) 433-0033" }, ]); expect(payload.contact).toBe("Jane"); - expect(payload.phone).toBe("(421) 433-0032"); + expect(payload.name).toBe("dal1"); }); - it("keeps legacy contact/phone untouched when no contacts are provided", () => { + it("sends the Site Phone as phone, never the main contact's phone", () => { const payload = mapLocationToBackend({ - name: "Legacy DC", - contact: "Legacy Person", - phone: "555-0199", + sitePhone: " (214) 555-0100 ", + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], + }); + const withoutSitePhone = mapLocationToBackend({ + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], }); - expect(payload.contact).toBe("Legacy Person"); - expect(payload.phone).toBe("555-0199"); - expect(payload.contacts).toEqual([]); + expect(payload.phone).toBe("(214) 555-0100"); + expect(withoutSitePhone.phone).toBe(""); + }); + + it("omits status, title and notes unless given, so an update keeps the stored values", () => { + const bare = mapLocationToBackend({ name: "DAL1", contacts: [] }); + const full = mapLocationToBackend({ + name: "DAL1", + status: "Active", + title: "Dallas DC", + notes: "Gate code 1234", + contacts: [], + }); + + expect(bare).not.toHaveProperty("status"); + expect(bare).not.toHaveProperty("title"); + expect(bare).not.toHaveProperty("notes"); + expect(full).toMatchObject({ status: "Active", title: "Dallas DC", notes: "Gate code 1234" }); + }); + + it("keeps the legacy contact name when the caller sends no contacts list", () => { + const addressOnly = mapLocationToBackend({ + name: "DAL1", + address: "3811 Distribution Dr", + contact: "Jane Doe", + }); + const withContacts = mapLocationToBackend({ + contact: "Jane Doe", + contacts: [{ name: "Bob", phone: "(421) 433-0033" }], + }); + + expect(addressOnly.contact).toBe("Jane Doe"); + expect(withContacts.contact).toBe("Bob"); + }); +}); + +describe("mapLocation site fields", () => { + it("reads client name, site phone, title and notes separately from the main contact", () => { + const detail = mapLocation({ + id: 9, + name: "DAL1", + title: "Dallas DC", + clientName: "Acme", + phone: "(214) 555-0100", + notes: "Gate code 1234", + contacts: [{ id: 31, name: "Carol", phone: "555-0143" }], + }); + const row = mapLocationListItem({ id: 9, accountName: "Acme", phone: "(214) 555-0100" }); + + expect(detail).toMatchObject({ + clientName: "Acme", + sitePhone: "(214) 555-0100", + title: "Dallas DC", + notes: "Gate code 1234", + phone: "555-0143", + }); + expect(row).toMatchObject({ clientName: "Acme", sitePhone: "(214) 555-0100" }); }); }); diff --git a/src/test/domain/locations/schemas/location-schema.test.ts b/src/test/domain/locations/schemas/location-schema.test.ts index ecf04b56..859a0592 100644 --- a/src/test/domain/locations/schemas/location-schema.test.ts +++ b/src/test/domain/locations/schemas/location-schema.test.ts @@ -7,7 +7,8 @@ import { const validBase: LocationFormValues = { ...locationDefaultValues, - name: "Dallas DC", + name: "DAL1", + accountId: "7", address: "3811 Distribution Dr", city: "Dallas", state: "TX", @@ -46,8 +47,8 @@ describe("locationSchema contacts", () => { expect(result.success).toBe(false); if (!result.success) { const messages = result.error.issues.map((issue) => issue.message); - expect(messages).toContain("Phone is required"); - expect(messages).toContain("Contact person is required"); + expect(messages).toContain("Contact phone is required"); + expect(messages).toContain("Contact name is required"); } }); @@ -63,3 +64,41 @@ describe("locationSchema contacts", () => { } }); }); + +describe("locationSchema site fields", () => { + const validSite: LocationFormValues = { + ...validBase, + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], + }; + + function messagesFor(values: LocationFormValues): string[] { + const result = locationSchema.safeParse(values); + return result.success ? [] : result.error.issues.map((issue) => issue.message); + } + + it("requires Site Code, Client, Street Address, City, State and the first contact", () => { + const messages = messagesFor({ ...locationDefaultValues }); + + expect(messages).toEqual( + expect.arrayContaining([ + "Site Code is required", + "Client is required", + "Street Address is required", + "City is required", + "State is required", + "Contact name is required", + "Contact phone is required", + ]), + ); + }); + + it("keeps Site Phone, Site Email and zip optional but checks the email format", () => { + expect( + locationSchema.safeParse({ ...validSite, sitePhone: "", contactEmail: "", zipCode: "" }) + .success, + ).toBe(true); + expect(messagesFor({ ...validSite, contactEmail: "not-an-email" })).toContain( + "Invalid email address", + ); + }); +}); 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]); + }); +});