Compare commits

...

29 commits

Author SHA1 Message Date
Alexandre Brandizzi
06f70c193d Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx
#	src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx
2026-09-25 17:26:10 -03:00
Alexandre Brandizzi
8e02031152
Merge pull request #268 from Sea-Haven-Industries/feat/ab/sh-313-completion-templates-page
feat(completion-docs): manage completion document templates
2026-09-25 20:18:17 +00:00
Alexandre Brandizzi
2b338b36c6 test(completion-docs): keep the dialog closed when dismissed mid-save
Dismissing the edit dialog with Escape or the Close button while an update
is still in flight must not bring it back when the save lands. The per-call
mutate callbacks that reopen it in view mode are dropped by TanStack Query
once the dialog unmounts, so this pins that behaviour: the test fails if the
save is moved to mutateAsync().then(), which would reopen the dialog.
2026-09-25 17:13:20 -03:00
Alexandre Brandizzi
85a45e9fc0
Merge pull request #279 from Sea-Haven-Industries/feat/ab/sh-389-rejected-uplift-queue
feat(uplifts): add the Rejected archive tab to Uplift Approvals (SH-389)
2026-09-25 20:08:16 +00:00
Alexandre Brandizzi
090c977659 fix(uplifts): report a failed approvals queue once, inline, without the global toast 2026-09-25 17:03:15 -03:00
Alexandre Brandizzi
d866cf1b87 fix(completion-docs): hide the empty-search row when templates fail to load 2026-09-25 17:00:23 -03:00
Alexandre Brandizzi
73f639c271
Merge pull request #269 from Sea-Haven-Industries/feat/ab/sh-322-overdue-type
feat(work-orders): send Overdue as its own work order type
2026-09-25 19:57:00 +00:00
Alexandre Brandizzi
a031239259 fix(uplifts): keep the count and pager when a refetch fails over loaded rows 2026-09-25 16:56:35 -03:00
Alexandre Brandizzi
7c38ebd579 Merge commit 'd281cbd663b7b4106f104cb40207e4d5d1271387' into HEAD 2026-09-25 16:51:25 -03:00
Alexandre Brandizzi
3ed45547db fix(uplifts): keep a failed queue from rendering as an empty archive 2026-09-25 16:48:46 -03:00
Alexandre Brandizzi
d281cbd663 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png
#	e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png
#	e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png
#	e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png
2026-09-25 16:46:17 -03:00
Alexandre Brandizzi
70501693c8 fix(uplifts): show an unavailable count pill while a queue is loading or failed 2026-09-25 13:43:11 -03:00
Alexandre Brandizzi
403bc66383
Merge pull request #271 from Sea-Haven-Industries/feat/ab/sh-386-password-policy
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
feat(account): My Account page with a live password checklist (SH-386)
2026-09-25 16:35:20 +00:00
Alexandre Brandizzi
dcc9429da8
Merge pull request #270 from Sea-Haven-Industries/feat/ab/sh-331-sites-panel
feat(sites): Add Site modal and site detail panel with in-place edit
2026-09-25 16:29:09 +00:00
Alexandre Brandizzi
70e52937aa feat(uplifts): add the rejected uplifts archive tab to Uplift Approvals 2026-09-25 13:23:32 -03:00
Alexandre Brandizzi
3f3a3701c3 feat(sites): suggest addresses as Street Address is typed
SH-331 requires Street Address suggestions after 3 typed characters,
with a pick filling Street, City and State and unpicked typing kept as
free text. SH-272, which SH-331 consolidates, specifies those
suggestions as mocked, and the shared AddressAutocompleteField that
Vendors already uses (SH-271) was built for Sites to reuse. No Places
key is involved.

The Add Site modal and the detail panel's edit mode now render that
field. A pick writes all three values through their own controllers,
so the form marks them dirty and Save changes enables in edit mode.
The field gains an opt-in requiredMarker prop so Sites keeps its label
asterisk while Vendors stays without one.
2026-09-25 13:10:03 -03:00
Alexandre Brandizzi
6e0967eaf0
Merge pull request #267 from Sea-Haven-Industries/feat/ab/wo-ids-filter
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
feat(notifications): SLA at-risk banner opening Work Orders on an exact id set
2026-09-25 15:58:37 +00:00
Alexandre Brandizzi
73090d05e4 fix(auth): keep change-password failures inline without a global toast 2026-09-25 12:26:31 -03:00
Alexandre Brandizzi
fe0d1a2571 fix(sites): keep the legacy contact name when an update sends no contacts 2026-09-25 12:23:18 -03:00
Alexandre Brandizzi
7fed941435 test(visual): refresh sidebar baselines for the My Account link 2026-09-25 12:14:19 -03:00
Alexandre Brandizzi
c5816f8907 fix(sites): name the site in the delete confirmation and toast 2026-09-25 11:55:09 -03:00
Alexandre Brandizzi
7de34d359e Merge branch 'feat/ab/wo-ids-filter' into feat/ab/sh-295-sla-alerts 2026-09-25 11:52:57 -03:00
Alexandre Brandizzi
74dd1292a4 fix(workorders): applying a panel filter replaces the exact id selection
The server honours only the id set when one is sent, so a facet applied during an ids drill-down showed as an active chip while doing nothing. Applying any facet now drops the selection; applying the panel unchanged keeps it. The ids param is also capped at the server limit whatever built the set.
2026-09-25 11:52:27 -03:00
Alexandre Brandizzi
cadfe1ee8d fix(auth): announce password rule changes to screen readers 2026-09-25 11:52:10 -03:00
Alexandre Brandizzi
551f6af208 refactor(sites): read optional site fields through one helper 2026-09-25 11:45:31 -03:00
Alexandre Brandizzi
a75b9c8be6 feat(sites): Add Site modal and site detail panel with in-place edit
- Sites page: Add Site modal with Site Code, Client, address, State/Area, optional Site Phone and Email, points of contact
- Duplicate Site Code shows inline under the field and keeps the modal open
- Row opens a non-modal detail panel; Street Address links to Google Maps; key-less map preview
- Edit switches the panel in place with Site Code read-only; Save needs a valid change, Cancel makes no call
- Site Phone is sent independently of the contacts; zip and title are carried through edits
- Retire the separate new/edit location pages
2026-09-25 11:43:30 -03:00
Alexandre Brandizzi
31e9db2d02 feat(notifications): SLA at-risk banner, 50% toast and breach acknowledgement
Dispatchers see a one-line banner and a one-time toast while a Reactive/Emergency work order is past half of its SEV response window; the banner opens the board on exactly those work orders and dismisses for the session. Missed deadlines stay in the notification center until acknowledged, and Clear all skips them.
2026-09-25 11:36:25 -03:00
Alexandre Brandizzi
e6250fba88 feat(account): My Account page with shared password checklist (SH-386)
Adds a My Account page, reachable from the sidebar, with the signed-in
user's read-only details and a Change Password section. The new password
is checked live against the shared password rules (6+ characters, an
uppercase letter, a number and a special character), which the invite
registration flow reuses. The current password is still verified by the
server first, and its rejection is shown on the current password field.
2026-09-25 11:16:59 -03:00
Alexandre Brandizzi
8a1a12bfa1 feat(work-orders): send Overdue as its own work order type
Overdue maps to work order type 8 on create, inline correction, board
reads and the type filter, instead of the Other enum and the past-due
flag. It lists the PM service catalog and never sends a severity. Past
Due moves to its own toggle in the advanced filter Status section and
narrows the other filters.
2026-09-25 11:06:21 -03:00
114 changed files with 3488 additions and 751 deletions

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 202 KiB

View file

@ -6,6 +6,7 @@ import { ProtectedRoute } from "@/components/auth/protected-route";
import { AppSidebar } from "@/components/layout/app-sidebar";
import { getSidebarWidth } from "@/config/menu";
import { AppTopbar } from "@/components/layout/app-topbar";
import { SlaAtRiskBanner } from "@/components/notifications/sla-at-risk-banner";
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
export default function AdminShellLayout() {
@ -40,6 +41,7 @@ export default function AdminShellLayout() {
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
>
<AppTopbar onToggleNav={handleToggleNav} />
<SlaAtRiskBanner />
<Box
component="main"

View file

@ -0,0 +1,33 @@
import { Text } from "@/components/ui/text";
import type { AuthUser } from "@/domain/auth/types/auth-user";
function ReadOnlyValue({ label, value }: { label: string; value: string | undefined }) {
return (
<div className="grid gap-1.5">
<Text variant="label" tone="muted" className="text-[12px]!">
{label}
</Text>
<Text variant="body" className="m-0 text-[14px]!">
{value || "—"}
</Text>
</div>
);
}
export function AccountSummaryCard({ user }: { user: AuthUser }) {
return (
<section
aria-label="Account details"
className="grid gap-4 rounded-lg border border-(--color-border) bg-(--color-bg-surface) p-5"
>
<div className="grid grid-cols-2 gap-3">
<ReadOnlyValue label="Name" value={user.fullname} />
<ReadOnlyValue label="Role" value={user.userRoles} />
</div>
<ReadOnlyValue label="Email" value={user.email} />
<Text variant="caption" tone="muted" className="text-[11.5px]!">
Contact your Admin to change your name, role, or email.
</Text>
</section>
);
}

View file

@ -0,0 +1,132 @@
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Box } from "@mui/material";
import { KeyRound } from "lucide-react";
import { PasswordChecklist } from "@/components/auth/password-checklist";
import { Button } from "@/components/ui/button";
import { FormField } from "@/components/ui/form-field";
import { PasswordField } from "@/components/ui/password-field";
import { Text } from "@/components/ui/text";
import {
CURRENT_PASSWORD_INCORRECT_MESSAGE,
changePasswordSchema,
type ChangePasswordFormValues,
} from "@/domain/auth/schemas/change-password-schema";
import { useChangePassword } from "@/domain/auth/use-cases/use-change-password";
const EMPTY_VALUES: ChangePasswordFormValues = {
currentPassword: "",
newPassword: "",
confirmPassword: "",
};
export function ChangePasswordSection() {
const changePassword = useChangePassword();
const {
register,
handleSubmit,
control,
reset,
setError,
formState: { errors },
} = useForm<ChangePasswordFormValues>({
resolver: zodResolver(changePasswordSchema),
defaultValues: EMPTY_VALUES,
mode: "onTouched",
});
const values = useWatch({ control });
const newPassword = values.newPassword ?? "";
const canSubmit = changePasswordSchema.safeParse(values).success;
const serverError = changePassword.error?.message;
const showServerError =
Boolean(serverError) && serverError !== CURRENT_PASSWORD_INCORRECT_MESSAGE;
const onSubmit = (form: ChangePasswordFormValues) => {
changePassword.mutate(
{
Currentpassword: form.currentPassword,
Newpassword: form.newPassword,
Confirmpassword: form.confirmPassword,
},
{
onSuccess: () => reset(EMPTY_VALUES),
onError: (error) => {
if (error.message === CURRENT_PASSWORD_INCORRECT_MESSAGE) {
setError("currentPassword", { message: CURRENT_PASSWORD_INCORRECT_MESSAGE });
}
},
},
);
};
return (
<Box
component="form"
aria-labelledby="change-password-heading"
onSubmit={handleSubmit(onSubmit)}
className="grid gap-4 rounded-lg border border-(--color-border) bg-(--color-bg-surface) p-5"
>
<div className="flex items-center gap-2">
<KeyRound size={15} aria-hidden="true" className="text-primary" />
<Text id="change-password-heading" variant="heading" className="m-0 text-[15px]!">
Change Password
</Text>
</div>
<FormField
label="Current password"
htmlFor="account-current-password"
required
error={Boolean(errors.currentPassword)}
helperText={errors.currentPassword?.message}
>
<PasswordField
{...register("currentPassword")}
id="account-current-password"
autoComplete="current-password"
error={Boolean(errors.currentPassword)}
/>
</FormField>
<FormField label="New password" htmlFor="account-new-password" required>
<PasswordField
{...register("newPassword")}
id="account-new-password"
autoComplete="new-password"
slotProps={{ htmlInput: { "aria-describedby": "account-password-rules" } }}
/>
<PasswordChecklist id="account-password-rules" password={newPassword} />
</FormField>
<FormField
label="Confirm new password"
htmlFor="account-confirm-password"
required
error={Boolean(errors.confirmPassword)}
helperText={errors.confirmPassword?.message}
>
<PasswordField
{...register("confirmPassword")}
id="account-confirm-password"
autoComplete="new-password"
error={Boolean(errors.confirmPassword)}
/>
</FormField>
{showServerError && <Text variant="error">{serverError}</Text>}
<div className="flex justify-end">
<Button
type="submit"
disabled={!canSubmit}
loading={changePassword.isPending}
loadingText="Updating…"
>
Update password
</Button>
</div>
</Box>
);
}

View file

@ -0,0 +1,24 @@
import { Box } from "@mui/material";
import { AccountSummaryCard } from "@/app/(protected)/account/_components/account-summary-card";
import { ChangePasswordSection } from "@/app/(protected)/account/_components/change-password-section";
import { PageHeader } from "@/components/ui/page-header";
import { useAuthContext } from "@/providers/auth-context";
export default function MyAccountPage() {
const { user } = useAuthContext();
return (
<Box className="flex flex-col gap-2 p-2.5">
<PageHeader
surface
title="My Account"
subtitle="Review your account and manage your password"
/>
<Box className="grid max-w-md gap-5 px-5 pb-5">
{user != null && <AccountSummaryCard user={user} />}
<ChangePasswordSection />
</Box>
</Box>
);
}

View file

@ -23,6 +23,8 @@ const BODY_CELL_SX = { fontSize: 13, padding: "8px 12px" } as const;
type CompletionTemplatesTableProps = {
templates: CompletionTemplate[];
isLoading: boolean;
/** The load failed: the page shows the error, so there is no search result to explain. */
hasError: boolean;
canEdit: boolean;
onView: (template: CompletionTemplate) => void;
onEdit: (template: CompletionTemplate) => void;
@ -79,7 +81,7 @@ function TemplateRow({
canEdit,
onView,
onEdit,
}: Omit<CompletionTemplatesTableProps, "templates" | "isLoading"> & {
}: Omit<CompletionTemplatesTableProps, "templates" | "isLoading" | "hasError"> & {
template: CompletionTemplate;
}) {
return (
@ -120,6 +122,7 @@ function TemplateRow({
export function CompletionTemplatesTable({
templates,
isLoading,
hasError,
canEdit,
onView,
onEdit,
@ -143,7 +146,7 @@ export function CompletionTemplatesTable({
</TableHead>
<TableBody>
{isLoading && <TableLoadingRow colSpan={4} />}
{!isLoading && templates.length === 0 && <EmptyRow />}
{!isLoading && !hasError && templates.length === 0 && <EmptyRow />}
{templates.map((template) => (
<TemplateRow
key={template.id}

View file

@ -72,6 +72,7 @@ export default function CompletionDocsPage() {
<CompletionTemplatesTable
templates={templates}
isLoading={filtered.isPending}
hasError={filtered.error !== null}
canEdit={permissions.canEdit}
onView={(template) => setDialog({ template, mode: "view" })}
onEdit={(template) => setDialog({ template, mode: "edit" })}

View file

@ -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<LocationFormValues>({
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 (
<Dialog
open={open}
onClose={() => {
if (!createLocation.isPending) {
close();
}
}}
fullWidth
maxWidth="sm"
aria-labelledby="add-site-title"
slotProps={{ backdrop: { sx: DIALOG_BLUR_BACKDROP_SX } }}
>
<Box component="form" noValidate onSubmit={form.handleSubmit(onSubmit)}>
<DialogTitle id="add-site-title">
Add Site
<Typography
variant="body2"
component="span"
className="block"
sx={{ color: "text.secondary" }}
>
Create a new client site record.
</Typography>
</DialogTitle>
<DialogContent dividers>
<SiteFormFields form={form} />
</DialogContent>
<DialogActions>
<Button variant="text" onClick={close} disabled={createLocation.isPending}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={createLocation.isPending}>
{createLocation.isPending ? "Adding..." : "Add Site"}
</Button>
</DialogActions>
</Box>
</Dialog>
);
}

View file

@ -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<AccountListItem, "id" | "name">;
interface ClientSelectProps {
control: Control<LocationFormValues>;
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<ClientOption[]>(() => {
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 (
<Controller
name="accountId"
control={control}
render={({ field }) => (
<Autocomplete<ClientOption>
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) => (
<TextField
{...params}
label="Client"
required
placeholder="Search clients..."
error={Boolean(error)}
helperText={error?.message}
/>
)}
/>
)}
/>
);
}

View file

@ -202,7 +202,7 @@ export function LocationContactsFieldArray({
<Box ref={rootRef}>
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="subtitle2" className="font-semibold uppercase">
Contacts
Points of contact
</Typography>
<Button
size="small"
@ -210,7 +210,7 @@ export function LocationContactsFieldArray({
startIcon={<AddIcon fontSize="small" />}
onClick={() => append({ name: "", phone: "" })}
>
Add Contact
Add point of contact
</Button>
</Stack>
<Stack spacing={1.5} className="mt-2">

View file

@ -23,10 +23,10 @@ export function LocationDeleteDialog({
}: LocationDeleteDialogProps) {
return (
<Dialog open={Boolean(target)} onClose={onClose}>
<DialogTitle>Delete Location</DialogTitle>
<DialogTitle>Delete this site?</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{target?.name}&quot;? This action cannot be undone.
This will permanently remove {target?.name} from Sites. This cannot be undone.
</DialogContentText>
</DialogContent>
<DialogActions>

View file

@ -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<LocationFormValues>;
errors: FieldErrors<LocationFormValues>;
register: UseFormRegister<LocationFormValues>;
}
interface LocationDetailsFieldsProps extends SharedFieldsProps {
accountOptions: AccountListItem[];
isLoadingAccounts: boolean;
onAccountSearchChange: (value: string) => void;
}
export function LocationDetailsFields({
accountOptions,
control,
errors,
isLoadingAccounts,
onAccountSearchChange,
register,
}: LocationDetailsFieldsProps) {
return (
<Stack spacing={2}>
<Typography variant="subtitle1" className="font-semibold">
Location Information
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("name")}
label="Name"
required
error={Boolean(errors.name)}
helperText={errors.name?.message}
fullWidth
/>
<Controller
name="accountId"
control={control}
render={({ field }) => (
<Autocomplete
options={accountOptions}
loading={isLoadingAccounts}
getOptionLabel={(option: AccountListItem) => 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) => (
<TextField {...params} label="Account" placeholder="Search accounts..." />
)}
className="flex-1"
/>
)}
/>
</Stack>
<TextField
{...register("address")}
label="Address"
required
error={Boolean(errors.address)}
helperText={errors.address?.message}
fullWidth
/>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("city")}
label="City"
required
error={Boolean(errors.city)}
helperText={errors.city?.message}
fullWidth
/>
<StateSelect control={control} error={errors.state} />
<TextField
{...register("zipCode")}
label="Zip Code"
required
error={Boolean(errors.zipCode)}
helperText={errors.zipCode?.message}
fullWidth
/>
</Stack>
</Stack>
);
}
export function LocationContactFields({ control, errors, register }: SharedFieldsProps) {
return (
<Stack spacing={2}>
<Typography variant="subtitle1" className="font-semibold">
Contact Information
</Typography>
<LocationContactsFieldArray control={control} errors={errors} register={register} />
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("contactEmail")}
label="Contact Email"
type="email"
required
error={Boolean(errors.contactEmail)}
helperText={errors.contactEmail?.message}
fullWidth
/>
<Controller
name="status"
control={control}
render={({ field }) => (
<FormControl fullWidth required error={Boolean(errors.status)}>
<InputLabel id="status-label">Status</InputLabel>
<Select {...field} labelId="status-label" label="Status" value={field.value}>
{LOCATION_STATUS_OPTIONS.map((status) => (
<MenuItem key={status} value={status}>
{status}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
</Stack>
</Stack>
);
}

View file

@ -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<LocationFormValues>({
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 (
<Box className="flex justify-center p-10">
<CircularProgress />
</Box>
);
}
return (
<Box className="mx-auto max-w-4xl p-4">
<Paper variant="outlined" className="p-6">
<Stack spacing={4}>
<Stack
direction="row"
sx={{
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h5" component="h1">
{isEdit ? "Edit Location" : "Create a Location"}
</Typography>
<Button variant="text" onClick={() => navigate("/locations")}>
Back to list
</Button>
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load location"}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
<LocationDetailsFields
accountOptions={accountOptions}
control={control}
errors={errors}
isLoadingAccounts={isLoadingAccounts}
onAccountSearchChange={setAccountSearch}
register={register}
/>
<LocationContactFields control={control} errors={errors} register={register} />
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Button variant="outlined" onClick={() => navigate("/locations")} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={isSaving}>
{isSaving ? "Saving..." : isEdit ? "Update Location" : "Create Location"}
</Button>
</Stack>
</Box>
</Stack>
</Paper>
</Box>
);
}

View file

@ -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 (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Location Name</TableCell>
<TableCell className="font-semibold">Address</TableCell>
<TableCell className="font-semibold">Site Code</TableCell>
<TableCell className="font-semibold">Client</TableCell>
<TableCell className="font-semibold">Street Address</TableCell>
<TableCell className="font-semibold">City</TableCell>
<TableCell className="font-semibold">State</TableCell>
<TableCell className="font-semibold">Phone</TableCell>
<TableCell className="font-semibold">Contact</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell className="font-semibold">POC Name</TableCell>
<TableCell className="font-semibold">POC Phone</TableCell>
<TableCell align="right" className="font-semibold">
Actions
<span className="sr-only">Actions</span>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
{isLoading && (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<TableCell colSpan={COLUMN_COUNT} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
)}
{!isLoading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<Typography sx={{ color: "text.secondary" }}>No locations found</Typography>
<TableCell colSpan={COLUMN_COUNT} align="center" className="py-10">
<Typography sx={{ color: "text.secondary" }}>No sites found</Typography>
</TableCell>
</TableRow>
) : (
)}
{!isLoading &&
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableRow
key={String(row.id)}
hover
selected={String(row.id) === selectedId}
onClick={() => onOpen(row)}
className="cursor-pointer"
>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => onEdit(row)}
onClick={(event) => {
event.stopPropagation();
onOpen(row);
}}
>
{row.name || "—"}
</Button>
</TableCell>
<TableCell>{row.clientName || "—"}</TableCell>
<TableCell>{row.address || "—"}</TableCell>
<TableCell>{row.city || "—"}</TableCell>
<TableCell>
<StateAreaCell state={row.state} />
</TableCell>
<TableCell>{row.phone || "—"}</TableCell>
<TableCell>
<LocationsContactsCell row={row} />
</TableCell>
<TableCell>{row.status || "—"}</TableCell>
<TableCell>{row.phone || "—"}</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton size="small" aria-label="Edit location" onClick={() => onEdit(row)}>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
aria-label="Delete location"
onClick={() => onDelete(row)}
aria-label={`Delete site ${row.name}`}
onClick={(event) => {
event.stopPropagation();
onDelete(row);
}}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
))}
</TableBody>
</Table>
</TableContainer>

View file

@ -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<LocationFormValues>({
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 (
<Box
component="form"
noValidate
onSubmit={form.handleSubmit(onSubmit)}
className="flex min-h-0 flex-1 flex-col"
aria-label="Edit site"
>
<Box className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
<SiteFormFields form={form} siteCodeReadOnly initialClient={initialClient} />
</Box>
<Stack
direction="row"
spacing={1}
className="border-t px-4 py-3"
sx={{ justifyContent: "flex-end", borderColor: "divider" }}
>
<Button variant="text" onClick={onDone} disabled={updateLocation.isPending}>
Cancel
</Button>
<Button
type="submit"
variant="contained"
disabled={!isDirty || !isValid || updateLocation.isPending}
>
{updateLocation.isPending ? "Saving..." : "Save changes"}
</Button>
</Stack>
</Box>
);
}

View file

@ -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 (
<Stack
direction="row"
className="border-b px-4 py-3"
sx={{ alignItems: "flex-start", justifyContent: "space-between", borderColor: "divider" }}
>
<Box>
<Typography variant="h6" component="h2">
{location?.name || "Site"}
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{location?.clientName || " "}
</Typography>
</Box>
<IconCloseButton label="Close site details" onClick={onClose} />
</Stack>
);
}
function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void }) {
const [mode, setMode] = useState<"view" | "edit">("view");
const { data: location, isLoading, error } = useLocationDetail(siteId);
return (
<>
<PanelHeader location={location} onClose={onClose} />
{isLoading && (
<Box className="flex justify-center p-8">
<CircularProgress size={28} />
</Box>
)}
{Boolean(error) && (
<Alert severity="error" className="m-4">
{error instanceof Error ? error.message : "Failed to load site"}
</Alert>
)}
{location !== undefined && mode === "edit" && (
<SiteDetailEdit siteId={siteId} location={location} onDone={() => setMode("view")} />
)}
{location !== undefined && mode === "view" && (
<>
<Box className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
<SiteDetailView location={location} />
</Box>
<Stack
direction="row"
spacing={1}
className="border-t px-4 py-3"
sx={{ justifyContent: "flex-end", borderColor: "divider" }}
>
<Button variant="contained" onClick={() => setMode("edit")}>
Edit
</Button>
</Stack>
</>
)}
</>
);
}
/**
* 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 (
<Drawer
anchor="right"
variant="persistent"
open={open}
slotProps={{
paper: {
role: "region",
"aria-label": "Site details",
className: "flex h-full flex-col border-l shadow-lg",
sx: { width: SITE_PANEL_WIDTH, maxWidth: "100vw" },
},
}}
>
{siteId !== null && <PanelBody key={siteId} siteId={siteId} onClose={onClose} />}
</Drawer>
);
}

View file

@ -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 (
<Stack spacing={0.25}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{label}
</Typography>
<Typography variant="body2" component="div">
{children}
</Typography>
</Stack>
);
}
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 (
<Stack spacing={2}>
<Field label="Site Code">{location.name || "—"}</Field>
<Field label="Client">{location.clientName || "—"}</Field>
<Field label="Street Address">
{Boolean(location.address) && (
<Link
href={buildSiteMapsUrl(location)}
target="_blank"
rel="noopener noreferrer"
underline="hover"
>
{location.address}
</Link>
)}
{!location.address && "—"}
</Field>
<Stack direction="row" spacing={2}>
<Field label="City">{location.city || "—"}</Field>
<Field label="State">{formatState(location.state)}</Field>
</Stack>
<SiteMapPreview
address={location.address}
city={location.city}
state={location.state}
zipCode={location.zipCode}
/>
<Field label="Site Phone">{location.sitePhone || "—"}</Field>
<Field label="Site Email">{location.contactEmail || "—"}</Field>
<Field label="Points of contact">
{contacts.length === 0 && "—"}
<Stack component="ul" spacing={0.5} className="m-0 list-none p-0">
{contacts.map((contact, index) => (
<li key={contact.id ?? `${contact.name}-${index}`}>
{index === 0 ? "Main" : `Contact ${index + 1}`}: {contact.name}
{Boolean(contact.phone) && ` · ${contact.phone}`}
</li>
))}
</Stack>
</Field>
</Stack>
);
}

View file

@ -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<LocationFormValues>;
/** Edit mode: Site Code is immutable after creation. */
siteCodeReadOnly?: boolean;
initialClient?: Pick<AccountListItem, "id" | "name">;
}
/** 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 (
<Stack spacing={2}>
<Controller
name="name"
control={control}
render={({ field }) => (
<TextField
{...field}
onChange={(event) => 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
/>
)}
/>
<ClientSelect control={control} error={errors.accountId} initialClient={initialClient} />
<AddressAutocompleteField
value={addressField.value}
onInputChange={addressField.onChange}
onSelect={(parts) => {
// One pick fills all three, each through its own controller so the
// form marks them dirty and revalidates (edit mode's Save depends on it).
addressField.onChange(parts.street);
cityField.onChange(parts.city);
stateField.onChange(normalizeStateCode(parts.state) ?? "");
}}
label="Street Address"
required
requiredMarker
error={Boolean(errors.address)}
helperText={
errors.address?.message ?? "Pick a suggestion to auto-fill City/State, or type your own."
}
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
{...cityField}
label="City"
required
error={Boolean(errors.city)}
helperText={errors.city?.message}
fullWidth
/>
<StateSelect control={control} error={errors.state} />
</Stack>
<SiteMapPreview address={address} city={city} state={state} />
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
{...register("sitePhone")}
label="Site Phone (optional)"
placeholder="(000) 000-0000"
error={Boolean(errors.sitePhone)}
helperText={errors.sitePhone?.message}
fullWidth
slotProps={{ htmlInput: { maxLength: 20 } }}
/>
<TextField
{...register("contactEmail")}
label="Site Email (optional)"
placeholder="site@company.com"
type="email"
error={Boolean(errors.contactEmail)}
helperText={errors.contactEmail?.message}
fullWidth
/>
</Stack>
<LocationContactsFieldArray control={control} errors={errors} register={register} />
</Stack>
);
}

View file

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

View file

@ -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<Location, "address" | "city" | "state" | "zipCode">;
/** 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 (
<Box>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Location preview
</Typography>
{complete && (
<Box
component="iframe"
title="Site location map"
src={buildSiteMapEmbedUrl(props)}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
className="mt-1 block w-full rounded border-0"
sx={{ height: 140 }}
/>
)}
{!complete && (
<Box
className="mt-1 flex items-center justify-center rounded border border-dashed"
sx={{ height: 140, borderColor: "divider" }}
>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Enter an address to preview the location
</Typography>
</Box>
)}
</Box>
);
}

View file

@ -1 +0,0 @@
export { default } from "../_components/location-form";

View file

@ -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<AreaZone[]>([]);
const [deleteTarget, setDeleteTarget] = useState<LocationListItem | null>(null);
const [isAddOpen, setIsAddOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(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 (
<Box className="flex flex-col gap-4 p-4">
<Box
className="flex flex-col gap-4 p-4"
sx={{ pr: { md: selectedId === null ? 2 : `${SITE_PANEL_WIDTH + 16}px` } }}
>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
@ -67,34 +77,17 @@ export default function LocationsListPage() {
justifyContent: "space-between",
}}
>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
<Typography variant="h5" component="h1">
Locations
Sites
</Typography>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: "center",
}}
>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ alignItems: "center" }}>
<AreaFacet
value={selectedAreas}
onChange={(zones) => {
@ -109,23 +102,24 @@ export default function LocationsListPage() {
setSearch(event.target.value);
setPage(0);
}}
placeholder="Search locations..."
placeholder="Search sites..."
className="min-w-[220px]"
/>
<Button variant="contained" onClick={() => navigate("/locations/new")}>
+ New Location
<Button variant="contained" onClick={() => setIsAddOpen(true)}>
Add Site
</Button>
</Stack>
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load locations"}
{error instanceof Error ? error.message : "Failed to load sites"}
</Alert>
)}
<LocationsTable
isLoading={isLoading}
rows={rows}
onEdit={(row) => navigate(`/locations/edit/${row.id}`)}
selectedId={selectedId}
onOpen={(row) => setSelectedId(String(row.id))}
onDelete={setDeleteTarget}
/>
<TablePagination
@ -140,6 +134,8 @@ export default function LocationsListPage() {
}}
rowsPerPageOptions={[12, 24, 48]}
/>
<AddSiteDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
<SiteDetailPanel siteId={selectedId} onClose={closePanel} />
<LocationDeleteDialog
target={deleteTarget}
isPending={deleteLocation.isPending}

View file

@ -1 +0,0 @@
export { default } from "./_components/location-form";

View file

@ -0,0 +1,52 @@
export const UPLIFT_APPROVAL_TABS = ["pending", "approved", "rejected"] as const;
export type UpliftApprovalTab = (typeof UPLIFT_APPROVAL_TABS)[number];
export function isUpliftApprovalTab(value: string): value is UpliftApprovalTab {
return (UPLIFT_APPROVAL_TABS as readonly string[]).includes(value);
}
const PENDING_HEADER_CELLS = [
"Work Order",
"Service / Vendor",
"Amount",
"Approved on WO",
"Notes",
"Attachments",
"Requested By",
"Waiting",
"Actions",
] as const;
const APPROVED_HEADER_CELLS = [
"Work Order",
"Service / Vendor",
"Amount",
"Approved on WO",
"Notes",
"Attachments",
"Requested By",
"Approved By",
"Approved At",
"Actions",
] as const;
// A rejection is terminal, so the archive ends with the decision and its reason
// instead of an Actions column.
const REJECTED_HEADER_CELLS = [
"Work Order",
"Service / Vendor",
"Amount",
"Approved on WO",
"Notes",
"Attachments",
"Requested By",
"Rejected By",
"Rejected At",
"Rejection Reason",
] as const;
export const UPLIFT_APPROVAL_HEADER_CELLS: Record<UpliftApprovalTab, readonly string[]> = {
pending: PENDING_HEADER_CELLS,
approved: APPROVED_HEADER_CELLS,
rejected: REJECTED_HEADER_CELLS,
};

View file

@ -16,14 +16,20 @@ import {
formatUpliftMoney,
isGrantedAmount,
} from "@/app/(protected)/uplifts/_components/format-uplift-money";
import {
UPLIFT_APPROVAL_HEADER_CELLS,
type UpliftApprovalTab,
} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs";
import {
RejectedEmptyState,
RejectedRowCells,
} from "@/app/(protected)/uplifts/_components/uplift-rejected-cells";
import { Text } from "@/components/ui/text";
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
export type UpliftApprovalTab = "pending" | "approved";
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
@ -236,65 +242,84 @@ function UpliftApprovalRow({
<AttachmentsCell row={row} onOpenAttachment={onOpenAttachment} />
</TableCell>
<TableCell>{row.requestedByVendorName || "—"}</TableCell>
{tab === "pending" ? (
<TableCell>
<WaitingCell row={row} />
</TableCell>
) : (
{tab === "pending" && (
<>
<TableCell>
<WaitingCell row={row} />
</TableCell>
<TableCell>
<PendingRowActions
row={row}
onApprove={onApprove}
onReject={onReject}
isDecisionPending={isDecisionPending}
/>
</TableCell>
</>
)}
{tab === "approved" && (
<>
<TableCell>{row.decidedByName || "—"}</TableCell>
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
<TableCell>
<ApprovedRowActions
row={row}
canRevoke={canRevoke}
onRevoke={onRevoke}
isDecisionPending={isDecisionPending}
/>
</TableCell>
</>
)}
<TableCell>
{tab === "pending" ? (
<PendingRowActions
row={row}
onApprove={onApprove}
onReject={onReject}
isDecisionPending={isDecisionPending}
/>
) : (
<ApprovedRowActions
row={row}
canRevoke={canRevoke}
onRevoke={onRevoke}
isDecisionPending={isDecisionPending}
/>
)}
{tab === "rejected" && <RejectedRowCells row={row} />}
</TableRow>
);
}
function UpliftApprovalsEmptyState({ tab }: { tab: UpliftApprovalTab }) {
if (tab === "rejected") return <RejectedEmptyState />;
if (tab === "approved") {
return (
<Box>
<Text variant="description">No approved uplifts yet</Text>
<Text variant="caption" tone="muted">
Uplifts you approve are archived here for reference.
</Text>
</Box>
);
}
return (
<Text variant="description" tone="muted">
No uplifts waiting for approval.
</Text>
);
}
// A failed queue is not an empty one: the page's error alert explains it, so the
// empty-state copy must not also claim the list has nothing in it.
function UpliftApprovalsEmptyRow({
tab,
colSpan,
hasError,
}: {
tab: UpliftApprovalTab;
colSpan: number;
hasError: boolean;
}) {
if (hasError) return null;
return (
<TableRow>
<TableCell colSpan={colSpan} align="center" className="py-10">
<UpliftApprovalsEmptyState tab={tab} />
</TableCell>
</TableRow>
);
}
const PENDING_HEADER_CELLS = [
"Work Order",
"Service / Vendor",
"Amount",
"Approved on WO",
"Notes",
"Attachments",
"Requested By",
"Waiting",
"Actions",
] as const;
const APPROVED_HEADER_CELLS = [
"Work Order",
"Service / Vendor",
"Amount",
"Approved on WO",
"Notes",
"Attachments",
"Requested By",
"Approved By",
"Approved At",
"Actions",
] as const;
export function UpliftApprovalsTable({
tab,
isLoading,
hasError,
rows,
canRevoke,
onOpenDetail,
@ -306,6 +331,7 @@ export function UpliftApprovalsTable({
}: {
tab: UpliftApprovalTab;
isLoading: boolean;
hasError: boolean;
rows: UpliftQueueItem[];
canRevoke: boolean;
onOpenDetail: (row: UpliftQueueItem) => void;
@ -315,7 +341,7 @@ export function UpliftApprovalsTable({
onOpenAttachment: (row: UpliftQueueItem) => void;
isDecisionPending: boolean;
}) {
const headerCells = tab === "pending" ? PENDING_HEADER_CELLS : APPROVED_HEADER_CELLS;
const headerCells = UPLIFT_APPROVAL_HEADER_CELLS[tab];
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small" aria-label={`${tab} uplift requests`}>
@ -336,22 +362,7 @@ export function UpliftApprovalsTable({
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={headerCells.length} align="center" className="py-10">
{tab === "pending" ? (
<Text variant="description" tone="muted">
No uplifts waiting for approval.
</Text>
) : (
<Box>
<Text variant="description">No approved uplifts yet</Text>
<Text variant="caption" tone="muted">
Uplifts you approve are archived here for reference.
</Text>
</Box>
)}
</TableCell>
</TableRow>
<UpliftApprovalsEmptyRow tab={tab} colSpan={headerCells.length} hasError={hasError} />
) : (
rows.map((row) => (
<UpliftApprovalRow

View file

@ -0,0 +1,28 @@
import HighlightOffOutlinedIcon from "@mui/icons-material/HighlightOffOutlined";
import { Box, TableCell } from "@mui/material";
import { Text } from "@/components/ui/text";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { formatDateTime } from "@/lib/time-utils";
export function RejectedRowCells({ row }: { row: UpliftQueueItem }) {
return (
<>
<TableCell>{row.decidedByName || "—"}</TableCell>
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
<TableCell sx={{ maxWidth: 260 }}>
<Text variant="caption" sx={{ whiteSpace: "pre-wrap" }}>
{row.decisionNote || "—"}
</Text>
</TableCell>
</>
);
}
export function RejectedEmptyState() {
return (
<Box className="flex flex-col items-center gap-2">
<HighlightOffOutlinedIcon color="disabled" aria-hidden />
<Text variant="description">No rejected uplifts.</Text>
</Box>
);
}

View file

@ -1,9 +1,11 @@
import { useState } from "react";
import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material";
import {
UpliftApprovalsTable,
UPLIFT_APPROVAL_TABS,
isUpliftApprovalTab,
type UpliftApprovalTab,
} from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs";
import { UpliftApprovalsTable } from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs";
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import {
@ -16,6 +18,7 @@ import { useAuthContext } from "@/providers/auth-context";
import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
import { Text } from "@/components/ui/text";
import type { UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
const PAGE_SIZE = 25;
@ -39,58 +42,71 @@ function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal:
);
}
const TAB_LABELS: Record<UpliftApprovalTab, string> = {
pending: "Pending",
approved: "Approved",
rejected: "Rejected",
};
// A tab's total is only known once its queue has loaded at least once; until then the
// pill and pager must not claim a count (a loading or failed queue is not an empty one).
// A failed refetch keeps the last loaded page, so its total stays usable.
function knownTotal(query: { data?: UpliftsQueueResult | undefined }): number | null {
return query.data ? query.data.totalCount : null;
}
function TabCountPill({ tab, count }: { tab: UpliftApprovalTab; count: number | null }) {
return (
<Chip
size="small"
label={count ?? "—"}
aria-label={count == null ? `${tab} uplift count unavailable` : `${count} ${tab} uplifts`}
/>
);
}
function UpliftApprovalsTabs({
tab,
pendingCount,
counts,
onChange,
}: {
tab: UpliftApprovalTab;
pendingCount: number;
counts: Record<UpliftApprovalTab, number | null>;
onChange: (_event: unknown, value: string) => void;
}) {
return (
<Tabs value={tab} onChange={onChange}>
<Tab
value="pending"
label={
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<span>Pending</span>
<Chip
size="small"
label={pendingCount}
aria-label={`${pendingCount} pending uplifts`}
/>
</Stack>
}
/>
<Tab value="approved" label="Approved" />
{UPLIFT_APPROVAL_TABS.map((value) => (
<Tab
key={value}
value={value}
label={
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<span>{TAB_LABELS[value]}</span>
<TabCountPill tab={value} count={counts[value]} />
</Stack>
}
/>
))}
</Tabs>
);
}
function UpliftApprovalsPagination({
tab,
count,
pendingPage,
approvedPage,
onPendingPageChange,
onApprovedPageChange,
page,
onPageChange,
}: {
tab: UpliftApprovalTab;
count: number;
pendingPage: number;
approvedPage: number;
onPendingPageChange: (page: number) => void;
onApprovedPageChange: (page: number) => void;
page: number;
onPageChange: (page: number) => void;
}) {
return (
<TablePagination
component="div"
count={count}
page={tab === "pending" ? pendingPage : approvedPage}
onPageChange={(_event, nextPage) =>
tab === "pending" ? onPendingPageChange(nextPage) : onApprovedPageChange(nextPage)
}
page={page}
onPageChange={(_event, nextPage) => onPageChange(nextPage)}
rowsPerPage={PAGE_SIZE}
rowsPerPageOptions={[PAGE_SIZE]}
/>
@ -108,22 +124,31 @@ function UpliftQueueError({ error }: { error: unknown }) {
export default function UpliftQueuePage() {
const [tab, setTab] = useState<UpliftApprovalTab>("pending");
const [pendingPage, setPendingPage] = useState(0);
const [approvedPage, setApprovedPage] = useState(0);
const [pages, setPages] = useState<Record<UpliftApprovalTab, number>>({
pending: 0,
approved: 0,
rejected: 0,
});
const { user } = useAuthContext();
const canRevokeUplifts = isAdminUser(user?.userRoles);
const pendingQuery = useUpliftsQueue({
status: "Pending",
page: pendingPage + 1,
page: pages.pending + 1,
pageSize: PAGE_SIZE,
});
const approvedQuery = useUpliftsQueue({
status: "Approved",
page: approvedPage + 1,
page: pages.approved + 1,
pageSize: PAGE_SIZE,
});
const rejectedQuery = useUpliftsQueue({
status: "Rejected",
page: pages.rejected + 1,
pageSize: PAGE_SIZE,
});
const queries = { pending: pendingQuery, approved: approvedQuery, rejected: rejectedQuery };
const controller = useUpliftApprovalController();
const {
@ -138,13 +163,18 @@ export default function UpliftQueuePage() {
openDetail,
} = controller;
const activeQuery = tab === "pending" ? pendingQuery : approvedQuery;
const activeQuery = queries[tab];
const rows = activeQuery.data?.items ?? [];
const pendingCount = pendingQuery.data?.totalCount ?? 0;
const counts = {
pending: knownTotal(pendingQuery),
approved: knownTotal(approvedQuery),
rejected: knownTotal(rejectedQuery),
};
const activeTotal = counts[tab];
const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null;
const handleTabChange = (_event: unknown, value: string) => {
if (value === "pending" || value === "approved") {
if (isUpliftApprovalTab(value)) {
setTab(value);
}
};
@ -152,11 +182,12 @@ export default function UpliftQueuePage() {
return (
<Box className="flex flex-col gap-4 p-4">
<UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} />
<UpliftApprovalsTabs tab={tab} pendingCount={pendingCount} onChange={handleTabChange} />
<UpliftApprovalsTabs tab={tab} counts={counts} onChange={handleTabChange} />
<UpliftQueueError error={activeQuery.error} />
<UpliftApprovalsTable
tab={tab}
isLoading={activeQuery.isLoading}
hasError={Boolean(activeQuery.error)}
rows={rows}
canRevoke={canRevokeUplifts}
onOpenDetail={openDetail}
@ -166,14 +197,13 @@ export default function UpliftQueuePage() {
onOpenAttachment={openUpliftEvidence}
isDecisionPending={approvePending || rejectPending || revokePending}
/>
<UpliftApprovalsPagination
tab={tab}
count={activeQuery.data?.totalCount ?? 0}
pendingPage={pendingPage}
approvedPage={approvedPage}
onPendingPageChange={setPendingPage}
onApprovedPageChange={setApprovedPage}
/>
{activeTotal != null && (
<UpliftApprovalsPagination
count={activeTotal}
page={pages[tab]}
onPageChange={(page) => setPages((current) => ({ ...current, [tab]: page }))}
/>
)}
{detailItem != null && (
<UpliftDetailModal
item={detailItem}

View file

@ -109,7 +109,11 @@ export function AdvancedSearchFilterSections({
/>
</AdvSection>
<AdvSection icon={CheckCircle2} label="Status" count={draft.statuses.size}>
<AdvSection
icon={CheckCircle2}
label="Status"
count={draft.statuses.size + (draft.pastDue ? 1 : 0)}
>
{selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)}
<div className="flex flex-wrap gap-1.5">
{ALL_WIZARD_STATUSES.map((status) => (
@ -122,6 +126,14 @@ export function AdvancedSearchFilterSections({
onClick={() => toggleIn("statuses", status)}
/>
))}
<AdvStatusPill
label="Past Due"
dotColor="var(--status-pastdue-text)"
selected={draft.pastDue}
selectedBg="var(--status-pastdue-bg)"
selectedText="var(--status-pastdue-text)"
onClick={() => setDraft((prev) => ({ ...prev, pastDue: !prev.pastDue }))}
/>
</div>
</AdvSection>

View file

@ -1,5 +1,5 @@
import type { Location } from "@/domain/locations/types/location";
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps";
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
@ -40,6 +40,5 @@ export function resolveLocationId(
}
export function buildMapsUrl(location: Location): string {
const query = formatLocationAddressPreview(location);
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
return buildSiteMapsUrl(location);
}

View file

@ -3,13 +3,9 @@ import { Text } from "@/components/ui/text";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import type { Location } from "@/domain/locations/types/location";
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps";
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
function buildMapsUrl(location: Location): string {
const query = formatLocationAddressPreview(location);
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
}
type WizardStepLocationSiteDetailsProps = {
selectedLoc: LocationOption | undefined;
locationName: string;
@ -58,7 +54,7 @@ export function WizardStepLocationSiteDetails({
)}
{Boolean(addressPreview) && (
<a
href={buildMapsUrl(locationDetail)}
href={buildSiteMapsUrl(locationDetail)}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"

View file

@ -6,9 +6,9 @@ import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import type { WorkOrderBoardQueryParams } from "@/domain/work-orders/types/work-order-board";
import {
clearAdvancedFilterGroup,
cloneAdvancedFilters,
countAdvancedFilters,
getInitialAdvancedFilters,
resolveAppliedAdvancedFilters,
} from "@/domain/work-orders/utils/advanced-filters-utils";
import { toBoardQueryParams } from "@/domain/work-orders/utils/board-query-params";
import { asDayPageSize, DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination";
@ -90,7 +90,7 @@ export function useWorkOrdersListFilters() {
};
const applyAdvancedFilters = (filters: WorkOrderAdvancedFilters) => {
setAdvApplied(cloneAdvancedFilters(filters));
setAdvApplied(resolveAppliedAdvancedFilters(filters));
setPage(0);
};

View file

@ -0,0 +1,63 @@
import { useState } from "react";
import { Circle, CircleCheck } from "lucide-react";
import { Text } from "@/components/ui/text";
import { evaluatePasswordRules, type PasswordRuleResult } from "@/domain/auth/password-policy";
import { cn } from "@/lib/utils";
export type PasswordChecklistProps = {
password: string;
id?: string;
className?: string;
};
function describeChanges(previous: PasswordRuleResult[], next: PasswordRuleResult[]): string {
return next
.filter((rule, index) => rule.met !== previous[index]?.met)
.map((rule) => `${rule.label}: ${rule.met ? "requirement met" : "requirement not met"}`)
.join(". ");
}
/**
* Live, per-rule feedback for the shared password policy. A polite status region
* announces only the rules whose state changed, so screen-reader users hear
* progress while typing without the whole list being re-read on each keystroke.
*/
export function PasswordChecklist({ password, id, className }: PasswordChecklistProps) {
const results = evaluatePasswordRules(password);
const signature = results.map((rule) => (rule.met ? "1" : "0")).join("");
const [previous, setPrevious] = useState({ signature, results });
const [announcement, setAnnouncement] = useState("");
if (previous.signature !== signature) {
setPrevious({ signature, results });
setAnnouncement(describeChanges(previous.results, results));
}
return (
<>
<ul
id={id}
aria-label="Password requirements"
className={cn("m-0 mt-2 grid list-none gap-1 p-0", className)}
>
{results.map(({ id: ruleId, label, met }) => (
<li key={ruleId} data-rule={ruleId} data-met={met} className="flex items-center gap-1.5">
{met ? (
<CircleCheck size={13} aria-hidden="true" className="text-(--color-success)" />
) : (
<Circle size={13} aria-hidden="true" className="text-(--color-text-muted)" />
)}
<Text variant="caption" tone={met ? "success" : "muted"}>
{label}
</Text>
<span className="sr-only">{met ? "requirement met" : "requirement not met"}</span>
</li>
))}
</ul>
<Text variant="feedback" className="sr-only">
{announcement}
</Text>
</>
);
}

View file

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

View file

@ -1,11 +1,14 @@
import { useState } from "react";
import { Link, useLocation } from "react-router";
import { Box, Drawer } from "@mui/material";
import { CircleUserRound } from "lucide-react";
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
import { useNavGroups } from "@/components/layout/use-nav-groups";
import { getActiveNavPath, getSidebarWidth, type NavItemDef } from "@/config/menu";
const ACCOUNT_PATH = "/account";
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
if (isActive) {
return {
@ -222,6 +225,21 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }:
</Box>
))}
</Box>
<Box
component="nav"
aria-label="Account navigation"
sx={{ borderTop: "1px solid var(--color-sidebar-border)", p: "8px" }}
>
<NavItem
label="My Account"
icon={CircleUserRound}
path={ACCOUNT_PATH}
collapsed={collapsed}
active={pathname === ACCOUNT_PATH}
onNavigate={mobile ? onClose : undefined}
/>
</Box>
</Box>
</Drawer>
);

View file

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

View file

@ -79,6 +79,11 @@ export function NotificationFeedList({ center }: NotificationFeedListProps) {
unread={center.unreadIds.has(item.id)}
onOpen={center.open}
onDismiss={center.dismiss}
onAcknowledge={center.acknowledge}
acknowledging={
item.target.workOrderId != null &&
item.target.workOrderId === center.acknowledgingWorkOrderId
}
/>
))}
</ul>

View file

@ -53,8 +53,37 @@ type NotificationRowProps = {
unread: boolean;
onOpen: (item: NotificationItem, url?: string) => void;
onDismiss: (item: NotificationItem) => void;
onAcknowledge?: (item: NotificationItem) => void;
acknowledging?: boolean;
};
/** A missed response deadline cannot be dismissed; acknowledging it is the only way out of the feed. */
function AcknowledgeButton({
item,
onAcknowledge,
acknowledging = false,
}: Pick<NotificationRowProps, "item" | "onAcknowledge" | "acknowledging">) {
return (
<button
type="button"
onClick={() => onAcknowledge?.(item)}
disabled={acknowledging}
title="Acknowledge"
aria-label="Acknowledge SLA breach"
className="shrink-0 cursor-pointer rounded-md border transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={{
fontSize: 11,
fontWeight: 600,
padding: "2px 8px",
color: "var(--destructive)",
borderColor: "var(--destructive)",
}}
>
{acknowledging ? "Acknowledging…" : "Acknowledge"}
</button>
);
}
/** A vendor conflict spans several work orders, so each one stays reachable from the row. */
function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "onOpen">) {
if (item.reason !== "vendor-conflict" || item.workOrders.length === 0) {
@ -84,7 +113,14 @@ function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "o
);
}
export function NotificationRow({ item, unread, onOpen, onDismiss }: NotificationRowProps) {
export function NotificationRow({
item,
unread,
onOpen,
onDismiss,
onAcknowledge,
acknowledging,
}: NotificationRowProps) {
return (
<li
className="group flex items-start gap-2.5 transition-colors hover:bg-[var(--color-bg-muted)]"
@ -136,6 +172,13 @@ export function NotificationRow({ item, unread, onOpen, onDismiss }: Notificatio
<X size={13} aria-hidden />
</button>
)}
{item.rowType === "acknowledge" && (
<AcknowledgeButton
item={item}
onAcknowledge={onAcknowledge}
acknowledging={acknowledging}
/>
)}
</li>
);
}

View file

@ -0,0 +1,90 @@
import { useEffect, useMemo } from "react";
import { AlertTriangle, ChevronRight, X } from "lucide-react";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed";
import { useNotificationSession } from "@/domain/notifications/session/notification-session-context";
import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
import { slaBannerText, slaHalfwayToastText } from "@/domain/notifications/utils/sla-alert-copy";
import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter";
const NO_WORK_ORDERS: SlaAtRiskWorkOrder[] = [];
/** Toasts each at-risk work order once per session, the first time the feed reports it. */
function useSlaHalfwayToasts(atRisk: SlaAtRiskWorkOrder[]) {
const { claimSlaToasts } = useNotificationSession();
useEffect(() => {
const claimed = new Set(claimSlaToasts(atRisk.map((workOrder) => workOrder.id)));
atRisk
.filter((workOrder) => claimed.has(workOrder.id))
.forEach((workOrder) =>
toast.warn(slaHalfwayToastText(workOrder), { toastId: `sla-at-risk-${workOrder.id}` }),
);
}, [atRisk, claimSlaToasts]);
}
/**
* One line at the top of every page while any of the user's Reactive/Emergency work orders is past
* half of its response window. Clicking it opens Work Orders on exactly those work orders; dismissing
* hides them for this session, and a work order that becomes at risk later brings the banner back.
*/
export function SlaAtRiskBanner() {
const navigate = useNavigate();
const { data } = useNotificationFeed();
const { slaBannerDismissedIds, dismissSlaBanner } = useNotificationSession();
const atRisk = data?.slaAtRisk ?? NO_WORK_ORDERS;
useSlaHalfwayToasts(atRisk);
const shown = useMemo(
() => atRisk.filter((workOrder) => !slaBannerDismissedIds.has(workOrder.id)),
[atRisk, slaBannerDismissedIds],
);
if (shown.length === 0) {
return null;
}
const ids = shown.map((workOrder) => workOrder.id);
return (
<div
role="region"
aria-label="Work orders at risk of missing their response deadline"
className="flex shrink-0 items-center"
style={{
gap: 10,
padding: "6px 16px",
background: "var(--destructive)",
color: "var(--destructive-foreground)",
}}
>
<button
type="button"
onClick={() => navigate(buildWorkOrderIdsHref(ids))}
title="View in Work Orders"
className="flex min-w-0 flex-1 cursor-pointer items-center text-left"
style={{ color: "inherit" }}
>
<span
className="inline-flex min-w-0 items-center transition-colors hover:bg-white/20"
style={{ gap: 8, padding: "3px 8px", marginLeft: -8, borderRadius: 4 }}
>
<AlertTriangle size={14} className="shrink-0" aria-hidden />
<span className="truncate" style={{ fontSize: 12.5, fontWeight: 600 }}>
{slaBannerText(shown)}
</span>
<ChevronRight size={14} className="shrink-0" aria-hidden />
</span>
</button>
<button
type="button"
onClick={() => dismissSlaBanner(ids)}
title="Dismiss"
aria-label="Dismiss SLA alert"
className="flex shrink-0 cursor-pointer items-center justify-center rounded transition-colors hover:bg-white/20"
style={{ width: 22, height: 22, color: "inherit" }}
>
<X size={14} aria-hidden />
</button>
</div>
);
}

View file

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

View file

@ -0,0 +1,53 @@
import { z } from "zod";
/**
* The single password rule for every surface that sets a password. It mirrors the
* server's Identity options: the character classes are ASCII, matching how the
* server classifies uppercase letters, digits and non-alphanumeric characters.
*/
export const PASSWORD_MIN_LENGTH = 6;
export type PasswordRuleId = "length" | "uppercase" | "number" | "special";
export type PasswordRule = {
id: PasswordRuleId;
label: string;
test: (password: string) => boolean;
};
export type PasswordRuleResult = {
id: PasswordRuleId;
label: string;
met: boolean;
};
export const PASSWORD_RULES: readonly PasswordRule[] = [
{
id: "length",
label: `At least ${PASSWORD_MIN_LENGTH} characters`,
test: (password) => password.length >= PASSWORD_MIN_LENGTH,
},
{ id: "uppercase", label: "One uppercase letter", test: (password) => /[A-Z]/.test(password) },
{ id: "number", label: "One number", test: (password) => /[0-9]/.test(password) },
{
id: "special",
label: "One special character",
test: (password) => /[^A-Za-z0-9]/.test(password),
},
];
export const PASSWORD_REQUIREMENTS_MESSAGE = "Password must meet every requirement below";
export const PASSWORDS_DO_NOT_MATCH_MESSAGE = "Passwords don't match";
export function evaluatePasswordRules(password: string): PasswordRuleResult[] {
return PASSWORD_RULES.map(({ id, label, test }) => ({ id, label, met: test(password) }));
}
export function meetsPasswordPolicy(password: string): boolean {
return PASSWORD_RULES.every((rule) => rule.test(password));
}
export const passwordSchema = z.string().refine(meetsPasswordPolicy, {
message: PASSWORD_REQUIREMENTS_MESSAGE,
});

View file

@ -0,0 +1,18 @@
import { z } from "zod";
import { PASSWORDS_DO_NOT_MATCH_MESSAGE, passwordSchema } from "@/domain/auth/password-policy";
export const CURRENT_PASSWORD_INCORRECT_MESSAGE = "Current password is incorrect";
export const changePasswordSchema = z
.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: passwordSchema,
confirmPassword: z.string(),
})
.refine((values) => values.newPassword === values.confirmPassword, {
path: ["confirmPassword"],
message: PASSWORDS_DO_NOT_MATCH_MESSAGE,
});
export type ChangePasswordFormValues = z.infer<typeof changePasswordSchema>;

View file

@ -3,14 +3,13 @@ import { toast } from "react-toastify";
import * as authApi from "@/domain/auth/api/auth-api";
import type { ChangePasswordPayload } from "@/domain/auth/types/auth-user";
/** Errors are rendered inline by the form that initiated the change. */
export function useChangePassword(): UseMutationResult<unknown, Error, ChangePasswordPayload> {
return useMutation({
mutationFn: (data: ChangePasswordPayload) => authApi.changePassword(data),
meta: { suppressErrorToast: true },
onSuccess: () => {
toast.success("Password changed successfully!");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to change password");
toast.success("Password updated");
},
});
}

View file

@ -18,6 +18,14 @@ function readString(record: Record<string, unknown>, ...keys: string[]): string
return "";
}
/** Like readString, but an empty value becomes undefined. */
function readOptionalString(
record: Record<string, unknown>,
...keys: string[]
): string | undefined {
return readString(record, ...keys) || undefined;
}
function readId(record: Record<string, unknown>): 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<string, unknown> {
const item = (typeof location === "object" && location !== null ? location : {}) as Record<
string,
@ -206,19 +229,25 @@ export function mapLocationToBackend(location: unknown): Record<string, unknown>
const mainContact = contacts[0];
const payload: Record<string, unknown> = {
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;

View file

@ -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<typeof locationSchema>;
@ -35,8 +49,9 @@ export const locationDefaultValues: LocationFormValues = {
address: "",
city: "",
state: "",
zipCode: "",
contacts: [{ name: "", phone: "" }],
sitePhone: "",
contactEmail: "",
status: "Active",
contacts: [{ name: "", phone: "" }],
zipCode: "",
title: "",
};

View file

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

View file

@ -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<Location, Error, LocationFormValues> {
const navigate = useNavigate();
/** Creates a site. A duplicate Site Code is left to the caller to show inline (no toast). */
export function useCreateLocation(): UseMutationResult<void, Error, LocationFormValues> {
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");
},
});
}

View file

@ -10,10 +10,10 @@ export function useDeleteLocation(): UseMutationResult<void, Error, string | num
mutationFn: (id: string | number) => 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");
},
});
}

View file

@ -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<Location, Error, UpdateLocationInput> {
const navigate = useNavigate();
/** Saves a site. The response carries no site, so the list and detail are refetched. */
export function useUpdateLocation(): UseMutationResult<void, Error, UpdateLocationInput> {
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");
},
});
}

View file

@ -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<string, unknown>).code : undefined;
return error.response.status === 409 || code === "DuplicateSiteCode";
}

View file

@ -0,0 +1,23 @@
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import type { Location } from "@/domain/locations/types/location";
type SiteAddress = Pick<Location, "address" | "city" | "state" | "zipCode">;
/** 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`;
}

View file

@ -1,4 +1,4 @@
import { apiGet } from "@/api/api";
import { apiGet, apiPostNoContent } from "@/api/api";
import {
notificationFeedSchema,
type NotificationFeed,
@ -7,4 +7,7 @@ import {
export const notificationsApi = {
feed: async (): Promise<NotificationFeed> =>
notificationFeedSchema.parse(await apiGet<unknown>("notifications")),
/** Records who acknowledged the missed response deadline in the work order's audit history. */
acknowledgeSlaBreach: (workOrderId: number): Promise<void> =>
apiPostNoContent(`notifications/sla/${workOrderId}/acknowledge`),
};

View file

@ -5,6 +5,11 @@ export type NotificationSession = {
readIds: ReadonlySet<string>;
dismiss: (ids: string[]) => void;
markRead: (ids: string[]) => void;
/** At-risk work orders hidden from the SLA banner for this session. */
slaBannerDismissedIds: ReadonlySet<number>;
dismissSlaBanner: (workOrderIds: number[]) => void;
/** Returns the ids not toasted yet this session and marks them toasted, so each toast fires once. */
claimSlaToasts: (workOrderIds: number[]) => number[];
};
export const NotificationSessionContext = createContext<NotificationSession | null>(null);

View file

@ -1,7 +1,7 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
import { NotificationSessionContext } from "./notification-session-context";
function addAll(current: ReadonlySet<string>, ids: string[]): ReadonlySet<string> {
function addAll<T>(current: ReadonlySet<T>, ids: T[]): ReadonlySet<T> {
if (ids.every((id) => current.has(id))) {
return current;
}
@ -17,6 +17,11 @@ function addAll(current: ReadonlySet<string>, ids: string[]): ReadonlySet<string
export function NotificationSessionProvider({ children }: { children: ReactNode }) {
const [dismissedIds, setDismissedIds] = useState<ReadonlySet<string>>(() => new Set());
const [readIds, setReadIds] = useState<ReadonlySet<string>>(() => new Set());
const [slaBannerDismissedIds, setSlaBannerDismissedIds] = useState<ReadonlySet<number>>(
() => new Set(),
);
// A ref, not state: claiming must be synchronous so a re-run effect can never toast twice.
const toastedSlaIds = useRef(new Set<number>());
const dismiss = useCallback((ids: string[]) => {
setDismissedIds((current) => addAll(current, ids));
@ -24,10 +29,34 @@ export function NotificationSessionProvider({ children }: { children: ReactNode
const markRead = useCallback((ids: string[]) => {
setReadIds((current) => addAll(current, ids));
}, []);
const dismissSlaBanner = useCallback((workOrderIds: number[]) => {
setSlaBannerDismissedIds((current) => addAll(current, workOrderIds));
}, []);
const claimSlaToasts = useCallback((workOrderIds: number[]) => {
const claimed = workOrderIds.filter((id) => !toastedSlaIds.current.has(id));
claimed.forEach((id) => toastedSlaIds.current.add(id));
return claimed;
}, []);
const value = useMemo(
() => ({ dismissedIds, readIds, dismiss, markRead }),
[dismissedIds, readIds, dismiss, markRead],
() => ({
dismissedIds,
readIds,
dismiss,
markRead,
slaBannerDismissedIds,
dismissSlaBanner,
claimSlaToasts,
}),
[
dismissedIds,
readIds,
dismiss,
markRead,
slaBannerDismissedIds,
dismissSlaBanner,
claimSlaToasts,
],
);
return (

View file

@ -50,9 +50,21 @@ const sectionSchema = z.object({
items: z.array(itemSchema),
});
/** A work order past half of its SEV response window, with the server-computed clock. */
const slaAtRiskSchema = z.object({
id: z.number(),
number: z.string().nullish(),
severity: z.number(),
startedAt: z.string(),
deadlineAt: z.string(),
percentElapsed: z.number(),
});
export const notificationFeedSchema = z.object({
generatedAt: z.string(),
sections: z.array(sectionSchema),
// Defaulted so a feed from a server without SLA alerts still parses.
slaAtRisk: z.array(slaAtRiskSchema).default([]),
});
export type NotificationSeverity = z.infer<typeof severitySchema>;
@ -60,3 +72,4 @@ export type NotificationTarget = z.infer<typeof targetSchema>;
export type NotificationItem = z.infer<typeof itemSchema>;
export type NotificationSection = z.infer<typeof sectionSchema>;
export type NotificationFeed = z.infer<typeof notificationFeedSchema>;
export type SlaAtRiskWorkOrder = z.infer<typeof slaAtRiskSchema>;

View file

@ -0,0 +1,44 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { notificationsApi } from "@/domain/notifications/api/notifications-api";
import type {
NotificationFeed,
NotificationItem,
} from "@/domain/notifications/types/notification-feed";
import { queryKeys } from "@/infra/query-key/query-key";
function isBreachRowFor(workOrderId: number) {
return (item: NotificationItem) =>
item.reason === "sla" &&
item.rowType === "acknowledge" &&
item.target.workOrderId === workOrderId;
}
/** The feed without the acknowledged work order's breach row, until the next poll confirms it. */
export function withoutSlaBreach(feed: NotificationFeed, workOrderId: number): NotificationFeed {
const matches = isBreachRowFor(workOrderId);
return {
...feed,
sections: feed.sections.map((section) => {
const removed = section.items.filter(matches);
if (removed.length === 0) return section;
return {
...section,
items: section.items.filter((item) => !matches(item)),
count: Math.max(section.count - removed.reduce((total, item) => total + item.count, 0), 0),
};
}),
};
}
export function useAcknowledgeSlaBreach() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (workOrderId: number) => notificationsApi.acknowledgeSlaBreach(workOrderId),
onSuccess: (_result, workOrderId) => {
queryClient.setQueryData<NotificationFeed>(queryKeys.notifications.feed(), (feed) =>
feed ? withoutSlaBreach(feed, workOrderId) : feed,
);
},
onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }),
});
}

View file

@ -0,0 +1,20 @@
import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed";
function workOrderLabel(workOrder: Pick<SlaAtRiskWorkOrder, "id" | "number">): string {
return workOrder.number != null && workOrder.number !== ""
? `WO #${workOrder.number}`
: `WO ${workOrder.id}`;
}
/** One line whatever the count, as the top-of-app banner shows it. */
export function slaBannerText(workOrders: Pick<SlaAtRiskWorkOrder, "id" | "number">[]): string {
if (workOrders.length === 1) {
return `${workOrderLabel(workOrders[0])} is at risk of missing its response deadline`;
}
return `${workOrders.length} work orders are at risk of missing their response deadline`;
}
/** The one-time toast when a work order is first seen past half of its response window. */
export function slaHalfwayToastText(workOrder: Pick<SlaAtRiskWorkOrder, "id" | "number">): string {
return `${workOrderLabel(workOrder)} is at 50% of its SLA response window`;
}

View file

@ -20,10 +20,13 @@ const SERVICE_WORK_ORDER_TYPE_API = {
Reactive: 6,
} as const;
const TYPE_TO_API: Record<Exclude<WOType, "Overdue">, number> = {
// The registry is keyed on PM, Reactive and Emergency; Overdue is PM work the
// client never confirmed, so it lists the PM catalog.
const TYPE_TO_API: Record<WOType, number> = {
PM: WORK_ORDER_TYPE_API.PM,
Reactive: WORK_ORDER_TYPE_API.Reactive,
Emergency: WORK_ORDER_TYPE_API.Emergency,
Overdue: WORK_ORDER_TYPE_API.PM,
};
function toApiInput(input: ServiceInput) {
@ -51,7 +54,6 @@ export const servicesApi = {
},
getActiveForWorkOrderType: async (workOrderType: WOType): Promise<Service[]> => {
if (workOrderType === "Overdue") return [];
const data = await apiGet<unknown>(API_PATHS.services.list, {
searchParams: { isActive: true, workOrderType: TYPE_TO_API[workOrderType] },
});

View file

@ -11,7 +11,6 @@ export function useServicesForWorkOrderType(
return useQuery({
queryKey: queryKeys.services.byWorkOrderType(effectiveType),
queryFn: () => servicesApi.getActiveForWorkOrderType(effectiveType),
enabled: effectiveType !== "Overdue",
staleTime: 60 * 1000,
});
}

View file

@ -10,5 +10,8 @@ export function useUpliftsQueue(
queryKey: queryKeys.uplifts.queue(params as Record<string, unknown>),
queryFn: () => upliftsApi.list(params),
placeholderData: keepPreviousData,
// The Uplift Approvals page reports a failed queue inline, so the global toast
// would announce the same failure twice.
meta: { suppressErrorToast: true },
});
}

View file

@ -1,4 +1,5 @@
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
import { typeNeedsSeverity } from "@/domain/work-orders/types/work-order-severity";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
@ -64,7 +65,7 @@ export function assignWizardServicePocFields(
payload.vendorNotes = vendorNotes;
}
if (draft.severity != null) {
if (draft.severity != null && typeNeedsSeverity(draft.type)) {
payload.severity = draft.severity;
}

View file

@ -28,7 +28,7 @@ const WO_TYPE_TO_API: Record<WOType, WorkOrderTypeApi> = {
Reactive: WORK_ORDER_TYPE_API.Reactive,
Overdue: WORK_ORDER_TYPE_API.Other,
Overdue: WORK_ORDER_TYPE_API.Overdue,
};
/** Leading token of a location label, e.g. `BK5` from `BK5 · Dallas` or `CHI-1042` from `CHI-1042 — Chicago`. */

View file

@ -35,6 +35,8 @@ export interface WorkOrderAdvancedFilters {
dispatchers: Set<string>;
regions: Set<string>;
statuses: Set<WizardWOStatus>;
/** Past Due overlay (any type); narrows the other filters, independent of the Overdue type. */
pastDue: boolean;
rescheduled: boolean;
carriedOver: boolean;
addOn: boolean;
@ -61,6 +63,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
dispatchers: new Set(),
regions: new Set(),
statuses: new Set(),
pastDue: false,
rescheduled: false,
carriedOver: false,
addOn: false,

View file

@ -17,6 +17,7 @@ export const WORK_ORDER_TYPE_API = {
Inspection: 5,
Reactive: 6,
AddOn: 7,
Overdue: 8,
Other: 99,
} as const;
@ -64,6 +65,7 @@ export const WORK_ORDER_TYPE_API_TO_FE: Record<number, WOType | ""> = {
[WORK_ORDER_TYPE_API.PM]: "PM",
[WORK_ORDER_TYPE_API.Reactive]: "Reactive",
[WORK_ORDER_TYPE_API.Emergency]: "Emergency",
[WORK_ORDER_TYPE_API.Overdue]: "Overdue",
};
export const LIFECYCLE_STATUS_API_TO_FE: Record<number, WizardWOStatus | "Canceled"> = {

View file

@ -192,6 +192,15 @@ export function appendStatusPmVendorDocChips(
clear: (current) => ({ ...current, statuses: new Set() }),
});
if (filters.pastDue) {
chips.push({
key: "pastdue",
group: "Status",
value: "Past Due",
clear: (current) => ({ ...current, pastDue: false }),
});
}
pushSetChip({
chips,
key: "pm",

View file

@ -45,12 +45,10 @@ const matchesDateRange: AdvancedFilterPredicate = (row, filters) => {
const matchesSite: AdvancedFilterPredicate = (row, filters) =>
!filters.sites.size || filters.sites.has(row.site);
const matchesType: AdvancedFilterPredicate = (row, filters) => {
if (!filters.types.size) return true;
const overdueMatch = filters.types.has("Overdue") && row.isPastDue;
const typeMatch = filters.types.has(row.type as never);
return overdueMatch || typeMatch;
};
const matchesType: AdvancedFilterPredicate = (row, filters) =>
!filters.types.size || filters.types.has(row.type as never);
const matchesPastDue: AdvancedFilterPredicate = (row, filters) => !filters.pastDue || row.isPastDue;
const matchesDispatcher: AdvancedFilterPredicate = (row, filters) => {
if (!filters.dispatchers.size) return true;
@ -95,6 +93,7 @@ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [
matchesType,
matchesDispatcher,
matchesStatus,
matchesPastDue,
matchesPmType,
matchesVendorTech,
matchesDoc,

View file

@ -36,6 +36,7 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte
upliftStatuses: new Set(filters.upliftStatuses ?? []),
workOrderIds: new Set(filters.workOrderIds ?? []),
unscheduledOnly: Boolean(filters.unscheduledOnly),
pastDue: Boolean(filters.pastDue),
};
}
@ -47,6 +48,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool
Boolean(filters.dispatchers?.size),
Boolean(filters.regions?.size),
Boolean(filters.statuses?.size),
Boolean(filters.pastDue),
filters.rescheduled,
filters.carriedOver,
filters.addOn,
@ -65,6 +67,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<number>() };
return next.workOrderIds.size > 0 && countAdvancedFilters(withoutIds) > 0 ? withoutIds : next;
}
function monthStart(y: number, m: number): string {
return `${y}-${pad2(m + 1)}-01`;
}

View file

@ -12,6 +12,7 @@ import {
} from "@/domain/work-orders/types/work-order-board";
import { mapWizardTypeToApi } from "@/domain/work-orders/mappers/work-order-board-mapper";
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
import { normalizeWorkOrderIds } from "@/domain/work-orders/utils/work-order-ids-filter";
import type { CompletionDocFilter } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard";
@ -41,30 +42,19 @@ function mapDispatchers(ids: Iterable<string>): string[] | undefined {
return list.length > 0 ? list : undefined;
}
/** Maps UI type filters to API `types` + separate `overdue` flag. Overdue is never sent as types=99. */
function mapWoTypeFilters(types: Iterable<WOType | string>): {
types?: number[];
overdue?: boolean;
} {
/** Maps UI type filters to API `types`. The Overdue type is sent as its own enum; past due uses `overdue`. */
function mapWoTypeFilters(types: Iterable<WOType | string>): number[] | undefined {
const list: number[] = [];
let overdue = false;
for (const type of types) {
if (type === "All") {
continue;
}
if (type === "Overdue") {
overdue = true;
continue;
}
const api = mapWizardTypeToApi(type as WOType);
if (api != null) {
list.push(api);
}
}
return {
types: list.length > 0 ? list : undefined,
overdue: overdue || undefined,
};
return list.length > 0 ? list : undefined;
}
function mapStatuses(statuses: Iterable<WizardWOStatus | string>): number[] | undefined {
@ -135,7 +125,9 @@ function mapSearchFacetParams(
/** Sent as one comma-separated `ids` value; the server then ignores every other filter. */
function mapWorkOrderIds(ids: Set<number> | 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. */
@ -144,14 +136,11 @@ export function toBoardQueryParams(
search: string,
): WorkOrderBoardQueryParams {
const [, weekTo] = weekRange(bar.weekMonday);
const typeFilters = bar.types.size > 0 ? mapWoTypeFilters(bar.types) : {};
return {
weekStart: bar.weekMonday,
weekEnd: weekTo,
dispatchers: mapDispatchers(bar.dispatcherIds),
types: typeFilters.types,
overdue: typeFilters.overdue,
types: mapWoTypeFilters(bar.types),
search: search.trim().length >= 2 ? search.trim() : undefined,
};
}
@ -208,7 +197,6 @@ export function toBoardSearchQueryParams(input: {
BOARD_SEARCH_MAX_PAGE_SIZE,
);
const typeFilters = mapWoTypeFilters(filters.types);
const trimmedSearch = input.search?.trim();
const search = trimmedSearch && trimmedSearch.length >= 2 ? trimmedSearch : undefined;
@ -221,8 +209,8 @@ export function toBoardSearchQueryParams(input: {
dateTo: date.dateTo,
includeDateless: date.includeDateless,
sites: filters.sites.size > 0 ? [...filters.sites] : undefined,
types: typeFilters.types,
overdue: typeFilters.overdue,
types: mapWoTypeFilters(filters.types),
overdue: filters.pastDue || undefined,
dispatchers: mapDispatchers(filters.dispatchers),
regions: filters.regions.size > 0 ? [...filters.regions] : undefined,
statuses: mapStatuses(filters.statuses),
@ -258,10 +246,6 @@ export function buildBoardSearchParamsRecord(
return out;
}
export function woTypeFilterIsOverdue(type: string): boolean {
return type === "Overdue";
}
/** Week-independent GET /board/search params for the pinned Unassigned queue. */
export function toUnassignedQueueQueryParams(
page = 0,

View file

@ -65,12 +65,7 @@ function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean {
}
function rowMatchesBarTypes(row: WorkOrderTableRow, types: Set<WOType>): boolean {
if (types.size === 0) {
return true;
}
const overdueMatch = types.has("Overdue") && row.isPastDue;
const typeMatch = types.has(row.type as WOType);
return overdueMatch || typeMatch;
return types.size === 0 || types.has(row.type as WOType);
}
export function filterByBarAndSearch(

View file

@ -0,0 +1,144 @@
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { toast } from "react-toastify";
import { beforeEach, describe, expect, it, vi } from "vitest";
import MyAccountPage from "@/app/(protected)/account";
import * as authApi from "@/domain/auth/api/auth-api";
import type { AuthUser } from "@/domain/auth/types/auth-user";
import { createAppQueryClient } from "@/lib/query/query-client";
import { renderWithProviders } from "@/test/test-utils";
const { toastSuccessMock } = vi.hoisted(() => ({ toastSuccessMock: vi.fn() }));
const SIGNED_IN_USER: AuthUser = {
token: "test-jwt-token",
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
email: "dana@seahavenind.com",
userRoles: "Dispatcher",
phoneNumber: "5551234567",
fullname: "Dana Reyes",
id: "user-1",
};
vi.mock("@/domain/auth/api/auth-api", () => ({
getSession: vi.fn(),
login: vi.fn(),
logout: vi.fn(),
changePassword: vi.fn(),
}));
vi.mock("react-toastify", () => ({
toast: { success: toastSuccessMock, error: vi.fn(), info: vi.fn() },
}));
/** Uses the app's query client, so the global mutation error toast is live. */
function renderPage() {
return renderWithProviders(<MyAccountPage />, {
route: "/account",
queryClient: createAppQueryClient(),
});
}
function field(label: RegExp) {
return screen.getByLabelText(label, { selector: "input" });
}
async function fillPasswords(
user: ReturnType<typeof userEvent.setup>,
{ current, next, confirm }: { current: string; next: string; confirm: string },
) {
await user.type(field(/^current password/i), current);
await user.type(field(/^new password/i), next);
await user.type(field(/^confirm new password/i), confirm);
}
describe("My Account page", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(authApi.getSession).mockResolvedValue(SIGNED_IN_USER);
});
it("shows the signed-in user's name, role and email as read-only details", async () => {
renderPage();
const details = await screen.findByRole("region", { name: "Account details" });
expect(within(details).getByText("Dana Reyes")).toBeInTheDocument();
expect(within(details).getByText("Dispatcher")).toBeInTheDocument();
expect(within(details).getByText("dana@seahavenind.com")).toBeInTheDocument();
expect(within(details).queryByRole("textbox")).not.toBeInTheDocument();
});
it("shows the live checklist for the new password", async () => {
const user = userEvent.setup();
renderPage();
const checklist = screen.getByRole("list", { name: "Password requirements" });
expect(within(checklist).getAllByText("requirement not met")).toHaveLength(4);
await user.type(field(/^new password/i), "Abc1!x");
expect(within(checklist).getAllByText("requirement met")).toHaveLength(4);
});
it("keeps Update password disabled until every rule is met and the confirmation matches", async () => {
const user = userEvent.setup();
renderPage();
const submit = screen.getByRole("button", { name: "Update password" });
await fillPasswords(user, { current: "Current1!", next: "abcdef", confirm: "abcdef" });
expect(submit).toBeDisabled();
await user.clear(field(/^new password/i));
await user.type(field(/^new password/i), "Abc1!x");
expect(submit).toBeDisabled();
await user.clear(field(/^confirm new password/i));
await user.type(field(/^confirm new password/i), "Abc1!x");
expect(submit).toBeEnabled();
expect(authApi.changePassword).not.toHaveBeenCalled();
});
it("tells the user when the confirmation does not match", async () => {
const user = userEvent.setup();
renderPage();
await fillPasswords(user, { current: "Current1!", next: "Abc1!x", confirm: "Abc1!y" });
await user.tab();
expect(await screen.findByText("Passwords don't match")).toBeInTheDocument();
});
it("sends the current and new password and clears the form after a successful change", async () => {
vi.mocked(authApi.changePassword).mockResolvedValue({ status: "Success " });
const user = userEvent.setup();
renderPage();
await fillPasswords(user, { current: "Current1!", next: "Abc1!x", confirm: "Abc1!x" });
await user.click(screen.getByRole("button", { name: "Update password" }));
await waitFor(() =>
expect(authApi.changePassword).toHaveBeenCalledWith({
Currentpassword: "Current1!",
Newpassword: "Abc1!x",
Confirmpassword: "Abc1!x",
}),
);
await waitFor(() => expect(field(/^current password/i)).toHaveValue(""));
expect(toastSuccessMock).toHaveBeenCalledWith("Password updated");
});
it("shows the server's current-password rejection on the current password field", async () => {
vi.mocked(authApi.changePassword).mockRejectedValue(new Error("Current password is incorrect"));
const user = userEvent.setup();
renderPage();
await fillPasswords(user, { current: "Wrong1!", next: "Abc1!x", confirm: "Abc1!x" });
await user.click(screen.getByRole("button", { name: "Update password" }));
expect(await screen.findByText("Current password is incorrect")).toBeInTheDocument();
expect(field(/^current password/i)).toHaveAttribute("aria-invalid", "true");
expect(toastSuccessMock).not.toHaveBeenCalled();
expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
});
});

View file

@ -109,6 +109,21 @@ describe("Completion Docs page", { timeout: 15_000 }, () => {
expect(screen.queryByRole("row", { name: /Backflow Test Report/ })).not.toBeInTheDocument();
});
it("reports a failed load without the empty-search message, including after a failed Retry", async () => {
api.apiGet.mockRejectedValue(new Error("Templates are unavailable"));
renderPage();
const retry = await screen.findByRole("button", { name: "Retry" });
expect(screen.getByText("Templates are unavailable")).toBeInTheDocument();
expect(screen.queryByText("No templates found")).not.toBeInTheDocument();
const callsBeforeRetry = api.apiGet.mock.calls.length;
await user.click(retry);
await waitFor(() => expect(api.apiGet.mock.calls.length).toBeGreaterThan(callsBeforeRetry));
expect(await screen.findByText("Templates are unavailable")).toBeInTheDocument();
expect(screen.queryByText("No templates found")).not.toBeInTheDocument();
});
it("opens a read-only detail panel from a row with every part of the template", async () => {
renderPage();
const dialog = await openTemplate("Backflow Test Report");

View file

@ -235,12 +235,23 @@ describe("Edit template", { timeout: 15_000 }, () => {
).toBeInTheDocument();
});
it("stays closed when the user closes it while a save is still in flight", async () => {
let finishSave: (value: unknown) => void = () => undefined;
api.apiPut.mockImplementationOnce(
() =>
it.each([
[
"Escape",
async () => {
// The disabled Save button drops focus; Escape is pressed from inside the form.
screen.getByRole("textbox", { name: /Document Title/ }).focus();
await user.keyboard("{Escape}");
},
],
["the Close button", () => user.click(screen.getByRole("button", { name: "Close" }))],
])("stays closed when dismissed with %s while a save is in flight", async (_, dismiss) => {
let finishSave: () => void = () => {};
const respond = api.apiPut.getMockImplementation()!;
api.apiPut.mockImplementation(
(...args: unknown[]) =>
new Promise((resolve) => {
finishSave = resolve;
finishSave = () => resolve(respond(...args));
}),
);
const dialog = await openTemplate("Backflow Test Report");
@ -249,9 +260,9 @@ describe("Edit template", { timeout: 15_000 }, () => {
await user.click(within(dialog).getByRole("button", { name: /Save changes/ }));
await waitFor(() => expect(api.apiPut).toHaveBeenCalled());
await user.click(within(dialog).getByRole("button", { name: "Close" }));
await dismiss();
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
finishSave({ ...BACKFLOW, name: "Backflow Test Report v2" });
finishSave();
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template updated"));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();

View file

@ -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(<AddSiteDialog open onClose={onClose} />, { 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();
});
});

View file

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

View file

@ -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<string, unknown> | 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(
<Routes>
<Route path="/locations/edit/:id" element={<LocationFormPage />} />
</Routes>,
{
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(<LocationFormPage />, {
route: "/locations/new",
routerProps: { initialEntries: ["/locations/new"] },
withAuth: false,
});
expect(screen.getByLabelText(/^State/)).toHaveValue("");
expect(screen.queryByText(/Area: /)).not.toBeInTheDocument();
});
});

View file

@ -8,6 +8,8 @@ function rowWithContacts(overrides: Partial<LocationListItem>): LocationListItem
return {
id: 1,
name: "Dallas DC",
clientName: "Acme",
sitePhone: "",
address: "3811 Distribution Dr",
city: "Dallas",
state: "TX",

View file

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

View file

@ -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<HTMLElement> {
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(<LocationsListPage />, { 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(<LocationsListPage />, { 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(<LocationsListPage />, { withAuth: false });
const region = await openSite("DAL1");
fireEvent.click(within(region).getByRole("button", { name: "Edit" }));
const save = within(region).getByRole("button", { name: "Save changes" });
const street = within(region).getByRole("combobox", { name: "Street Address" });
await user.clear(street);
await user.type(street, "415 Murphy Rd");
const picked = suggestAddresses("415 Murphy Rd").find((option) => option.city !== "Dallas");
if (picked === undefined) throw new Error("expected a non-Dallas suggestion");
await user.click(await screen.findByRole("option", { name: picked.label }));
await waitFor(() => expect(save).toBeEnabled());
expect(within(region).getByRole("textbox", { name: "City" })).toHaveValue(picked.city);
fireEvent.click(save);
await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
expect(update).toHaveBeenCalledWith(
"1",
expect.objectContaining({
address: "415 Murphy Rd",
city: picked.city,
state: picked.state,
}),
);
});
it("Cancel discards edits and returns to view without an API call", async () => {
renderWithProviders(<LocationsListPage />, { withAuth: false });
const region = await openSite("DAL1");
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(<LocationsListPage />, { 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(<LocationsListPage />, { 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(<LocationsListPage />, { 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();
});
});

View file

@ -29,7 +29,7 @@ function StateSelectHarness({
zipCode: "70068",
contacts: [{ name: "Jane", phone: "(421) 433-0032" }],
contactEmail: "jane@example.com",
status: "Active",
accountId: "7",
},
});

View file

@ -100,7 +100,7 @@ describe("Uplift Approvals approved tab", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
expect(screen.getByRole("columnheader", { name: "Approved By" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Approved At" })).toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: "Waiting" })).not.toBeInTheDocument();
@ -125,7 +125,7 @@ describe("Uplift Approvals approved tab", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
const revoke = await screen.findByRole("button", { name: "Revoke" });
expect(revoke).toBeEnabled();
fireEvent.click(revoke);
@ -149,7 +149,7 @@ describe("Uplift Approvals approved tab", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
const revoke = await screen.findByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();
fireEvent.mouseOver(revoke);
@ -172,7 +172,7 @@ describe("Uplift Approvals approved tab", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
expect(await screen.findByText("No approved uplifts yet")).toBeInTheDocument();
expect(
screen.getByText("Uplifts you approve are archived here for reference."),

View file

@ -167,7 +167,7 @@ describe("Uplift Approvals shows one amount per request", () => {
queueState.data = queueOf(approvedItem);
await renderQueue();
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
expect(amountCell()).toHaveTextContent(UPLIFT);
fireEvent.click(screen.getByRole("button", { name: "Revoke" }));

View file

@ -0,0 +1,237 @@
import { fireEvent, screen, within } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type {
UpliftQueueItem,
UpliftsQueueParams,
UpliftsQueueResult,
} from "@/domain/uplifts/types/uplift";
import { formatDateTime } from "@/lib/time-utils";
import { renderWithProviders } from "@/test/test-utils";
const queueState = vi.hoisted(() => ({
byStatus: {} as Record<string, UpliftsQueueResult | undefined>,
errorByStatus: {} as Record<string, Error | undefined>,
requestedStatuses: [] as Array<string | undefined>,
}));
const baseItem: UpliftQueueItem = {
id: 1,
status: "Pending",
currentNTE: 500,
requestedNTE: 750,
delta: 250,
vendorReason: "Second hydro-jetting pass on the adjoining line",
requestedAt: "2026-06-21T10:05:00Z",
requestedByVendorName: "Alyssa Dispatcher",
decidedAt: "",
decidedByName: "",
decisionNote: "",
requiredTier: 1,
canDecide: true,
expiresAt: "",
notificationStatus: "",
notificationError: "",
evidenceDocumentId: "doc-9",
evidenceFileName: "quote.pdf",
evidenceContentType: "application/pdf",
evidenceSizeBytes: 1024,
dispatchNumber: "DSP-1",
poNumber: "PO-1",
vendorCompanyName: "Drain Cleaning Bros",
workOrderId: 10,
dispatchId: 20,
woNumber: "WO-25603",
site: "CHI2",
serviceName: "Drains",
technicianName: "",
workOrderDispatcherName: "",
workOrderScheduledDate: "",
attachmentCount: 1,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
approvedOnWoTotal: null,
workOrderClosed: false,
};
const newestRejection: UpliftQueueItem = {
...baseItem,
id: 31,
status: "Rejected",
woNumber: "WO-31",
decidedAt: "2026-06-21T16:40:00Z",
decidedByName: "Adam Moussa",
decisionNote: "Outside this work order's scope — raise a separate reactive WO.",
canDecide: false,
};
const olderRejection: UpliftQueueItem = {
...baseItem,
id: 30,
status: "Rejected",
woNumber: "WO-30",
requestedByVendorName: "Derek Dispatcher",
decidedAt: "2026-05-26T15:05:00Z",
decidedByName: "Grace Admin",
decisionNote: "Quote is above market for this scope.",
canDecide: false,
};
const pendingItem: UpliftQueueItem = { ...baseItem, id: 40, woNumber: "WO-PENDING" };
const approvedItem: UpliftQueueItem = {
...baseItem,
id: 50,
status: "Approved",
woNumber: "WO-APPROVED",
decidedAt: "2026-06-01T09:00:00Z",
decidedByName: "Adam Moussa",
};
function page(items: UpliftQueueItem[], totalCount = items.length): UpliftsQueueResult {
return { items, totalCount, page: 1, pageSize: 25, pendingExposureTotal: null };
}
vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({
useUpliftsQueue: (params: UpliftsQueueParams) => {
queueState.requestedStatuses.push(params.status);
const data = queueState.byStatus[params.status ?? ""];
const error = queueState.errorByStatus[params.status ?? ""] ?? null;
return {
data,
isLoading: data === undefined && error === null,
isFetching: false,
error,
refetch: vi.fn(),
};
},
}));
vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
useApproveUplift: () => ({ isPending: false, mutate: vi.fn() }),
useRejectUplift: () => ({ isPending: false, mutate: vi.fn() }),
useRevokeUplift: () => ({ isPending: false, mutate: vi.fn() }),
}));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<object>()),
useAuthContext: () => ({ user: { userRoles: "Admin" } }),
}));
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
useUpliftsCanApprove: () => ({ data: true }),
}));
async function openRejectedTab() {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: /^Rejected/ }));
return screen.getByRole("table", { name: "rejected uplift requests" });
}
describe("Uplift Approvals rejected tab", () => {
// Load the page module once up front so the first test's budget is not spent on a
// cold import of the whole route.
beforeAll(async () => {
await import("@/app/(protected)/uplifts/index");
}, 60_000);
beforeEach(() => {
queueState.requestedStatuses = [];
queueState.errorByStatus = {};
queueState.byStatus = {
Pending: page([pendingItem]),
Approved: page([approvedItem]),
Rejected: page([newestRejection, olderRejection], 12),
};
});
it("shows the rejected total in the tab count pill next to the other tabs' pills", async () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
const rejectedTab = screen.getByRole("tab", { name: /^Rejected/ });
expect(within(rejectedTab).getByLabelText("12 rejected uplifts")).toHaveTextContent("12");
expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument();
expect(screen.getByLabelText("1 approved uplifts")).toBeInTheDocument();
expect(queueState.requestedStatuses).toContain("Rejected");
});
it("never claims a count for a queue that is still loading or failed to load", async () => {
queueState.byStatus.Rejected = undefined;
queueState.errorByStatus.Rejected = new Error("Forbidden");
queueState.byStatus.Approved = undefined;
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
const rejectedPill = screen.getByLabelText("rejected uplift count unavailable");
expect(rejectedPill).toHaveTextContent("—");
expect(screen.getByLabelText("approved uplift count unavailable")).toHaveTextContent("—");
expect(screen.queryByLabelText("0 rejected uplifts")).not.toBeInTheDocument();
expect(screen.queryByLabelText("0 approved uplifts")).not.toBeInTheDocument();
expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument();
});
it("keeps the count and pager for kept rows when a rejected refetch fails", async () => {
queueState.errorByStatus.Rejected = new Error("Failed to load uplift queue");
const table = await openRejectedTab();
expect(screen.getByRole("alert")).toHaveTextContent("Failed to load uplift queue");
expect(within(table).getAllByRole("row")).toHaveLength(3);
expect(screen.getByLabelText("12 rejected uplifts")).toHaveTextContent("12");
expect(screen.getByText(/of 12/)).toBeInTheDocument();
});
it("shows the load error instead of an empty archive when the rejected queue fails", async () => {
queueState.byStatus.Rejected = undefined;
queueState.errorByStatus.Rejected = new Error("Failed to load uplift queue");
const table = await openRejectedTab();
expect(screen.getByRole("alert")).toHaveTextContent("Failed to load uplift queue");
expect(within(table).queryByText("No rejected uplifts.")).not.toBeInTheDocument();
expect(within(table).queryByTestId("HighlightOffOutlinedIcon")).not.toBeInTheDocument();
expect(screen.queryByText(/of 0/)).not.toBeInTheDocument();
});
it("lists only rejected uplifts, in the server's most-recently-rejected order", async () => {
const table = await openRejectedTab();
const dataRows = within(table).getAllByRole("row").slice(1);
expect(dataRows).toHaveLength(2);
expect(dataRows[0]).toHaveTextContent("WO-31");
expect(dataRows[1]).toHaveTextContent("WO-30");
expect(within(table).queryByText("WO-PENDING")).not.toBeInTheDocument();
expect(within(table).queryByText("WO-APPROVED")).not.toBeInTheDocument();
});
it("shows who requested, who rejected, when, and the rejection reason inline", async () => {
const table = await openRejectedTab();
for (const header of ["Requested By", "Rejected By", "Rejected At", "Rejection Reason"]) {
expect(within(table).getByRole("columnheader", { name: header })).toBeInTheDocument();
}
const [, firstRow] = within(table).getAllByRole("row");
expect(firstRow).toHaveTextContent("Alyssa Dispatcher");
expect(firstRow).toHaveTextContent("Adam Moussa");
expect(firstRow).toHaveTextContent(formatDateTime(newestRejection.decidedAt));
expect(
within(firstRow).getByText("Outside this work order's scope — raise a separate reactive WO."),
).toBeVisible();
});
it("offers no action on a rejected uplift", async () => {
const table = await openRejectedTab();
expect(within(table).queryByRole("columnheader", { name: "Actions" })).not.toBeInTheDocument();
for (const name of [/^Approve/, /^Reject$/, /^Revoke/]) {
expect(within(table).queryByRole("button", { name })).not.toBeInTheDocument();
}
});
it("shows an icon and the empty copy when nothing has been rejected", async () => {
queueState.byStatus.Rejected = page([]);
const table = await openRejectedTab();
expect(within(table).getByText("No rejected uplifts.")).toBeInTheDocument();
expect(within(table).getByTestId("HighlightOffOutlinedIcon")).toBeInTheDocument();
expect(screen.getByLabelText("0 rejected uplifts")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,154 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
import { AdvancedSearchFilterSections } from "@/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections";
import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
import {
EMPTY_ADVANCED_FILTERS,
type WorkOrderAdvancedFilters,
} from "@/domain/work-orders/types/work-order-advanced-filters";
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
canContinueWizardStep1,
EMPTY_WIZARD_DRAFT,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import { buildAdvancedFilterChips } from "@/domain/work-orders/utils/advanced-filters-utils";
import { getClosabilityGaps } from "@/domain/work-orders/utils/wo-closability";
const USERS = [{ id: "sarah", name: "Sarah Chen" }];
describe("Overdue work order type", () => {
it("offers Overdue in the Create WO wizard and never asks for a severity", () => {
const onPatch = vi.fn();
const { rerender } = render(
<WizardStepTypeSchedule
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM" }}
users={USERS}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /No client response — due date passed/ }));
expect(onPatch).toHaveBeenCalledWith(
expect.objectContaining({ type: "Overdue", severity: null }),
);
const overdueDraft: WorkOrderWizardDraft = { ...EMPTY_WIZARD_DRAFT, type: "Overdue" };
rerender(
<WizardStepTypeSchedule
draft={overdueDraft}
users={USERS}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
expect(screen.queryByText(/Severity \(from APM\)/)).not.toBeInTheDocument();
expect(canContinueWizardStep1(overdueDraft)).toBe(true);
});
it("creates the work order with the Overdue enum and no severity", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
type: "Overdue",
severity: 2,
locationId: "1",
siteCode: "BK5",
});
expect(payload.workOrderType).toBe(WORK_ORDER_TYPE_API.Overdue);
expect(payload.severity ?? null).toBeNull();
});
it("corrects a type inline to Overdue without opening the severity dialog", async () => {
const onSave = vi.fn();
const user = userEvent.setup();
render(<TypeCell value="Reactive" severity={3} onSave={onSave} />);
await user.click(screen.getByRole("button", { name: "Type of WO, Reactive" }));
await user.click(screen.getByRole("button", { name: /^Overdue$/ }));
expect(onSave).toHaveBeenCalledWith("Overdue", null);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("reads the Overdue enum back from the board as the Overdue type", () => {
const row = mapWorkOrderTableRow({ id: 1, workOrderType: WORK_ORDER_TYPE_API.Overdue });
expect(row.type).toBe("Overdue");
});
it("closes with the completion document, not photos", () => {
const base = {
id: "1",
dispatcherId: "sarah",
dispatcherName: "Sarah Chen",
company: "Gateway Plumbing",
vendorId: "",
tech: "",
completedDate: "2026-09-24",
woNumber: "24971",
pm: "Backflow Preventers",
mediaCount: 0,
};
expect(
getClosabilityGaps({ ...base, type: "Overdue" as const, docStatus: "No" as const }),
).toEqual(["Completion document"]);
expect(
getClosabilityGaps({ ...base, type: "Overdue" as const, docStatus: "Yes" as const }),
).toEqual([]);
const row = {
id: "1",
type: "Overdue",
pm: "Backflow Preventers",
docStatus: "No",
rowVersion: "1",
};
render(
<SlideOverCompletionTab
row={row as WorkOrderTableRow}
isReadOnly={false}
media={[]}
onOpenCompletionDoc={vi.fn()}
/>,
);
expect(screen.getByText("Completion Document")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /open completion document/i })).toBeEnabled();
});
it("keeps a separate Past Due status toggle in the advanced filters", () => {
let draft: WorkOrderAdvancedFilters = { ...EMPTY_ADVANCED_FILTERS };
const setDraft = vi.fn(
(update: (prev: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters) => {
draft = update(draft);
},
);
render(
<AdvancedSearchFilterSections
draft={draft}
setDraft={setDraft as never}
toggleIn={vi.fn()}
setMany={vi.fn()}
sites={[]}
users={[]}
pmTypes={[]}
vendorTechs={[]}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /^Status/ }));
fireEvent.click(screen.getByRole("button", { name: /Past Due/ }));
expect(draft.pastDue).toBe(true);
expect(draft.types.size).toBe(0);
expect(buildAdvancedFilterChips(draft)).toContainEqual(
expect.objectContaining({ group: "Status", value: "Past Due" }),
);
});
});

Some files were not shown because too many files have changed in this diff Show more