Compare commits

...

15 commits

Author SHA1 Message Date
Alexandre Brandizzi
3e5fba4d2b
Merge pull request #225 from Sea-Haven-Industries/feat/ab/sh-292-notification-center
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
SH-292: Notification Center panel and feed
2026-09-18 23:33:59 +00:00
Alexandre Brandizzi
cbfed3dde8
Merge pull request #226 from Sea-Haven-Industries/feat/ab/sh-209-uplift-detail-modal
SH-209: Align the Uplift Detail modal with the spec
2026-09-18 23:33:55 +00:00
Alexandre Brandizzi
f25a3dd628
Merge pull request #228 from Sea-Haven-Industries/fix/ab/sh-379-poc-persist
Persist manual POC edits via dedicated POC endpoint (SH-379)
2026-09-18 23:33:52 +00:00
Adam Moussa
41b26692c3
Merge pull request #238 from Sea-Haven-Industries/chore/pr-template-and-cleanup
chore(repo): add PR template, fix README conventions, drop tracked scratch files
2026-09-18 23:30:58 +00:00
1698d53d38
chore(repo): add PR template, fix README conventions, drop tracked scratch files
The org PR template pre-filled Summary / Validation / Tests / Notes here while
PRs in this repository use Summary / Changes and value / Ticket. A repo template
now overrides the org one, and the review framework gains the description
contract plus a note on the divergence from the org pr-policy workflow, which
is not wired in.

README: the CI badge tracked the retired dev branch; branches come from main,
not dev; the fix/ prefix replaces bug/; staging exists alongside dev; and PRs
now merge through the merge queue.

Cleanup: four PR description drafts under tmp/ were tracked; they are removed
and /tmp/ is ignored.
2026-09-18 19:19:55 -04:00
Codex Review Integration
84568dd737 refactor(workorders): extract board patch api module
work-orders-api.ts exceeded the 500-line godfile cap after adding
updatePoc. Move patchBoardField/updatePoc and their shared response
handling into work-order-board-patch-api.ts and re-export through
workOrdersApi, mirroring workOrderBoardDocumentsApi.
2026-09-18 15:03:59 -03:00
Codex Review Integration
3b1da21abb fix(workorders): preserve partial POC edits 2026-09-18 15:00:01 -03:00
Codex Review Integration
5b0c758a9f fix(workorders): persist manual POC edits via dedicated POC endpoint (SH-379)
The UI dropped manual POC edits before they reached the API: the patch
mapper listed pocName/pocPhone/pocNotes as local-only keys and the
slide-over draft excluded them from Save, so the optimistic edit vanished
on refetch and the completion freeze captured the Site contact instead.

- Emit one composite POC op from table patches and route it through a new
  workOrdersApi.updatePoc (PATCH workorders/{id}/poc), reusing the board
  patch row/error contract (409 conflict with currentState, 422 validation).
- Include POC scalars in slide-over edit keys so dirty state and Save carry
  them; site dialog and slide-over now both persist POC edits.
2026-09-18 14:44:06 -03:00
Codex Review Integration
8b122ad71c fix: satisfy governance hook line limits 2026-09-18 14:38:27 -03:00
Codex Review Integration
062a1e26c8 fix: resolve notification review findings 2026-09-18 14:23:43 -03:00
Codex Review Integration
edd2e7efe2 test(visual): refresh mobile navigation baseline for the notification bell 2026-09-18 13:52:00 -03:00
Codex Review Integration
988c96c42d fix(notifications): keep vendor reminder actions and refresh header baselines
No Vendor opens the work order form on vendor assignment and Vendor Conflict
links each work order, as the vendor reminders did. Header baselines include the
notification bell, and the top bar user menu moves to its own component to stay
within the function-size gate. The vendor e2e now covers the feed contract.
2026-09-18 13:34:12 -03:00
Codex Review Integration
33d0ab4fd6 test(uplifts): scope modal permission and date assertions to what they prove
The permission spec asserts the work order renders from the queue item
and, separately, that only the approved-on-WO breakdown degrades to
Unavailable. The Requested At assertion derives the local calendar day
so it holds in every runner time zone.
2026-09-18 13:02:11 -03:00
Codex Review Integration
dcf944d7dc SH-292: Notification Center bell panel and feed page
Header bell panel and /notifications page share one feed from GET /api/notifications: labeled sections with live counts, unread styling, session-only dismiss and clear all (acknowledge rows excluded), and deep links to the unassigned queue and a work order's tab via ?wo=&tab=.
2026-09-18 12:55:54 -03:00
Codex Review Integration
3c84d851c0 feat(uplifts): align uplift detail modal with SH-209
Two sections, Work order and Uplift request, divided by a single rule.
Work order shows Service, Vendor / Technician, Assigned Dispatcher and
Scheduled from the queue item, so it no longer fetches the work order
and no longer degrades to Unavailable for account-scoped staff. Uplift
request shows Requested By, Requested At, the justification in a
bordered block, the approved-on-WO breakdown and a horizontal
attachment row whose chips open the evidence in a new tab. Every field
renders data or an explicit placeholder. The footer shows Reject and
Approve for pending uplifts, Revoke for approved ones and nothing for
read-only records; closing is the header X.
2026-09-18 12:53:55 -03:00
82 changed files with 2709 additions and 946 deletions

22
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,22 @@
<!--
Title: type(scope): description (SH-123)
type is one of feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert, release.
Product work carries its SH key at the end of the title. Platform or security work carries PLAT or SEC.
Docs-only and configuration-only chores may omit the key.
Branch: feature/, fix/, hotfix/, chore/, docs/, refactor/, release/ plus a kebab-case description. No Jira keys in branch names.
Scope: one logical change per PR. If the title needs "and", split it.
Body: verifiable facts about the change. No validation transcripts, no deployment notes, no AI attribution footers.
The layout below is this repository's contract (REVIEW_AND_PR_FRAMEWORK.md, section 8). Keep the three headings.
-->
## Summary
<!-- What changed and why, in plain language. Two to four sentences. -->
## Changes and value
<!-- Grouped by area, each with the value it delivers. Bullets, bold lead-in per bullet. -->
## Ticket
<!-- The board key(s) this PR delivers, one per line. "None" when nothing applies. -->

3
.gitignore vendored
View file

@ -49,3 +49,6 @@ seed-data.sql
# python
__pycache__/
*.py[cod]
# local scratch
/tmp/

View file

@ -1,6 +1,6 @@
# SHOC Frontend (`shoc-frontend-new`)
[![CI](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml/badge.svg?branch=dev)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
[![CI](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
[![Deploy](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml/badge.svg)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)
![React](https://img.shields.io/badge/React-087EA4?logo=react&logoColor=white)
@ -13,7 +13,8 @@ the legacy SHOC frontend — new code follows the IrisLoan.Admin conventions
documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md).
- **GitHub:** `Sea-Haven-Industries/shoc-frontend-new`
- **Hosted at:** <https://dev.seahaven.com> (dev environment; the only environment today)
- **Hosted at:** <https://dev.seahaven.com> (dev, deployed from `main`) and
<https://staging.seahaven.com> (staging, deployed from `vX.Y.Z-staging` tags)
- **Backend API:** `https://api.dev.seahaven.com/api` (called directly, cross-origin) — source: `Sea-Haven-Industries/shoc-backend`
## Architecture
@ -122,16 +123,19 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
## Contributing
- Branch from `dev` with a kebab-case description and a prefix matching the
work: `feature/`, `bug/`, `hotfix/`, `chore/`, `docs/`, or `refactor/`
- Branch from `main` with a kebab-case description and a prefix matching the
work: `feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, or `refactor/`
(e.g. `feature/vendor-portal-filters`, `chore/sea-haven-branding`).
- Commit messages follow
[Conventional Commits](https://www.conventionalcommits.org) — commitlint
rejects anything else at commit time.
- Open PRs against `main`. Protected branches need a green CI run and an
approving review from a code owner
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals.
Merged branches are deleted automatically.
- Open PRs against `main`. The PR body uses the three-section layout the
template pre-fills: Summary, Changes and value, Ticket. `main` needs the
`governance` and `Build and test / ci` checks and an approving review from a
code owner (`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale
approvals. PRs merge through the merge queue, so a branch does not need to
be updated with `main` before it merges. Merged branches are deleted
automatically.
- PRs cannot mix `terraform/` with deployable application files (G13). Workflow,
docs, and gate-script changes may travel with either side. `deploy-web.yaml`
still ignores `terraform/**` so a Terraform-only merge does not sync the bucket.

View file

@ -90,3 +90,19 @@ that includes both `terraform/` and deployable application files (`src/`,
`public/`, `pages/`, `config/`, `index.html`, Vite/tsconfig, or `.env*`)
fails G13. Workflow, docs, and gate-script changes may travel with either
side.
## 8. PR description contract (minimal)
- **Summary** — what changed and why, in plain language.
- **Changes and value** — grouped by area, each with the value it delivers.
- **Ticket** — the board key(s) when one applies; "None" otherwise.
- Link any ADR relied upon.
Avoid boilerplate: no deployment notes, no validation transcripts, no
"residual-risk" theatre, no AI signatures. The validation story lives in the
check run results and the close-out, not in the PR body.
`.github/PULL_REQUEST_TEMPLATE.md` pre-fills this layout and overrides the org
template, whose Summary / Validation / Tests / Notes headings this repository
does not use. The org `callable-pr-policy` workflow hard-codes those four
headings; it is not wired into this repository, and this layout is the reason.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 191 KiB

View file

@ -78,49 +78,54 @@ test.describe("Vendor operations", () => {
.toBeGreaterThanOrEqual(280);
});
test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({
test("keeps 48-hour reminders and overlap conflicts reachable from the notification feed", async ({
page,
}) => {
await authenticate(page);
await page.route("**/api/vendor-operations/notifications", (route) =>
const workOrder = { kind: "workOrder", tab: "info" };
await page.route("**/api/notifications", (route) =>
fulfillJson(route, {
items: [
generatedAt: "2026-07-23T12:00:00Z",
sections: [
{
id: "no-vendor-501",
type: "NoVendor",
reason: "no-vendor",
label: "No Vendor",
severity: "Medium",
group: "No Vendor",
title: "No vendor assigned — 48h to service",
workOrderId: 501,
workOrderNumber: "WO-501",
triggeredAt: "2026-07-23T12:00:00Z",
scheduledAt: "2026-07-25T12:00:00Z",
action: { label: "Choose vendor", href: "/workorders/edit/501?assignVendor=1" },
},
{
id: "vendor-conflict-801-802",
type: "VendorConflict",
severity: "Medium",
group: "Vendor Conflict",
title: "Vendor has overlapping appointments",
triggeredAt: "2026-07-23T12:00:00Z",
workOrders: [
count: 1,
items: [
{
workOrderId: 501,
workOrderNumber: "WO-501",
start: "2026-07-25T12:00:00Z",
end: "2026-07-25T14:00:00Z",
},
{
workOrderId: 502,
workOrderNumber: "WO-502",
start: "2026-07-25T13:00:00Z",
end: "2026-07-25T15:00:00Z",
id: "no-vendor-501",
reason: "no-vendor",
severity: "Medium",
rowType: "dismissable",
title: "WO #WO-501 starts within 48h with no vendor assigned",
count: 1,
triggeredAt: "2026-07-23T12:00:00Z",
target: { ...workOrder, workOrderId: 501 },
workOrders: [{ id: 501, number: "WO-501" }],
},
],
actions: [
{ label: "WO-501", href: "/workorders/501" },
{ label: "WO-502", href: "/workorders/502" },
},
{
reason: "vendor-conflict",
label: "Vendor Conflict",
severity: "Medium",
count: 2,
items: [
{
id: "vendor-conflict-501-502",
reason: "vendor-conflict",
severity: "Medium",
rowType: "dismissable",
title: "Acme has overlapping appointments",
count: 2,
triggeredAt: "2026-07-23T12:00:00Z",
target: { ...workOrder, workOrderId: 501 },
workOrders: [
{ id: 501, number: "WO-501" },
{ id: 502, number: "WO-502" },
],
},
],
},
],
@ -129,21 +134,19 @@ test.describe("Vendor operations", () => {
await page.goto("/notifications");
await expect(page.getByRole("heading", { name: "Items requiring attention" })).toBeVisible();
await expect(page.getByRole("heading", { name: "No Vendor" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Vendor Conflict" })).toBeVisible();
await expect(page.getByRole("link", { name: "Choose vendor" })).toHaveAttribute(
"href",
"/workorders/edit/501?assignVendor=1",
);
await expect(page.getByRole("link", { name: "WO-501" })).toHaveAttribute(
await expect(page.getByRole("link", { name: "WO #WO-501" })).toHaveAttribute(
"href",
"/workorders/501",
);
await expect(page.getByRole("link", { name: "WO-502" })).toHaveAttribute(
await expect(page.getByRole("link", { name: "WO #WO-502" })).toHaveAttribute(
"href",
"/workorders/502",
);
await page.getByRole("button", { name: /starts within 48h with no vendor assigned/ }).click();
await expect(page).toHaveURL(/\/workorders\/edit\/501\?assignVendor=1$/);
});
test("filters insights and exposes both export formats", async ({ page }) => {

View file

@ -30,6 +30,7 @@ export const API_PATHS = {
boardCreate: "workorders/board",
boardSearch: "workorders/board/search",
boardPatch: (id: string | number) => `workorders/${id}/board`,
pocPatch: (id: string | number) => `workorders/${id}/poc`,
boardDetail: (id: string | number) => `workorders/${id}/detail`,
completionTemplates: "workorders/completion-templates",
comments: (id: string | number) => `workorders/${id}/comments`,

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 { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
export default function AdminShellLayout() {
const theme = useTheme();
@ -25,29 +26,31 @@ export default function AdminShellLayout() {
return (
<ProtectedRoute>
<Box className="flex h-screen w-full overflow-hidden bg-background">
<AppSidebar
collapsed={isDesktop ? navCollapsed : false}
mobile={!isDesktop}
open={isDesktop || mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
/>
<Box
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
>
<AppTopbar onToggleNav={handleToggleNav} />
<NotificationSessionProvider>
<Box className="flex h-screen w-full overflow-hidden bg-background">
<AppSidebar
collapsed={isDesktop ? navCollapsed : false}
mobile={!isDesktop}
open={isDesktop || mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
/>
<Box
component="main"
className="flex-1 overflow-auto"
sx={{ backgroundColor: "var(--color-bg-page)" }}
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
>
<Outlet />
<AppTopbar onToggleNav={handleToggleNav} />
<Box
component="main"
className="flex-1 overflow-auto"
sx={{ backgroundColor: "var(--color-bg-page)" }}
>
<Outlet />
</Box>
</Box>
</Box>
</Box>
</NotificationSessionProvider>
</ProtectedRoute>
);
}

View file

@ -1,104 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Paper,
Stack,
Typography,
} from "@mui/material";
import { Link } from "react-router";
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
import { Box } from "@mui/material";
import { NotificationFeedActions } from "@/components/notifications/notification-feed-actions";
import { NotificationFeedList } from "@/components/notifications/notification-feed-list";
import { useNotificationCenter } from "@/components/notifications/use-notification-center";
import { PageHeader } from "@/components/ui/page-header";
export default function NotificationsPage() {
const {
data = [],
isLoading,
error,
} = useQuery({
queryKey: ["vendor-operations", "notifications"],
queryFn: vendorOperationsApi.notifications,
refetchInterval: 30_000,
});
const groups = data.reduce((result, item) => {
const items = result.get(item.group) ?? [];
items.push(item);
result.set(item.group, items);
return result;
}, new Map<string, typeof data>());
const center = useNotificationCenter();
return (
<Box className="flex flex-col gap-4 p-4">
<Box>
<Typography variant="h5" component="h1">
Items requiring attention
</Typography>
<Typography color="text.secondary">
Medium-severity vendor reminders refresh automatically. They do not create banners or
toasts.
</Typography>
<Box className="flex flex-col gap-2 p-2.5">
<PageHeader
surface
title="Notifications"
actions={
<div className="flex items-center gap-3">
<NotificationFeedActions center={center} />
</div>
}
/>
<Box className="max-w-3xl px-5 pb-5">
<NotificationFeedList center={center} />
</Box>
{isLoading && <CircularProgress aria-label="Loading notifications" />}
{Boolean(error) && <Alert severity="error">{error?.message}</Alert>}
{!isLoading && !error && data.length === 0 && (
<Paper variant="outlined" className="p-6">
<Typography>No vendor alerts require attention.</Typography>
</Paper>
)}
{[...groups.entries()].map(([group, items]) => (
<Paper key={group} variant="outlined" className="p-4">
<Stack direction="row" spacing={1} className="mb-3" sx={{ alignItems: "center" }}>
<Typography variant="h6">{group}</Typography>
<Chip label={items.length} size="small" />
</Stack>
<Stack spacing={2}>
{items.map((item) => (
<Paper key={item.id} variant="outlined" className="p-3">
<Stack
direction={{ xs: "column", md: "row" }}
sx={{ gap: 2, justifyContent: "space-between" }}
>
<Box>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Chip label={item.severity} color="warning" size="small" />
<Typography sx={{ fontWeight: 600 }}>{item.title}</Typography>
</Stack>
<Typography color="text.secondary" className="mt-1">
{item.workOrderNumber ??
item.workOrders?.map((workOrder) => workOrder.workOrderNumber).join(" ↔ ")}
</Typography>
{item.scheduledAt != null && (
<Typography variant="caption">
Scheduled {new Date(item.scheduledAt).toLocaleString()}
</Typography>
)}
</Box>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
{item.action != null && (
<Button component={Link} to={item.action.href} variant="contained">
{item.action.label}
</Button>
)}
{item.actions?.map((action) => (
<Button
key={action.href}
component={Link}
to={action.href}
variant="outlined"
>
{action.label}
</Button>
))}
</Stack>
</Stack>
</Paper>
))}
</Stack>
</Paper>
))}
</Box>
);
}

View file

@ -0,0 +1,27 @@
const DATE_FORMAT: Intl.DateTimeFormatOptions = {
month: "short",
day: "numeric",
year: "numeric",
};
/** Formats an API UTC instant as local "Jan 15, 2026 · 10:00 AM"; "" when absent or invalid. */
export function formatUpliftDateTime(value: string): string {
if (!value) return "";
const hasZone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value);
const date = new Date(hasZone ? value : `${value}Z`);
if (Number.isNaN(date.getTime())) return "";
const time = date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
return `${date.toLocaleDateString("en-US", DATE_FORMAT)} · ${time}`;
}
/**
* Formats a work-order schedule as "Apr 10, 2026". The schedule is a calendar day,
* so only its date part is read and it never shifts with the viewer's time zone.
*/
export function formatUpliftCalendarDate(value: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
if (match == null) return "";
const [, year, month, day] = match;
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
return date.toLocaleDateString("en-US", { ...DATE_FORMAT, timeZone: "UTC" });
}

View file

@ -2,12 +2,27 @@ import { toast } from "react-toastify";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
function reportEvidenceError(error: unknown): void {
toast.error(error instanceof Error ? error.message : "Unable to download evidence right now.");
}
export function openUpliftEvidence(row: UpliftQueueItem): void {
void upliftsApi
.downloadEvidence(row.id, row.evidenceFileName || "uplift-evidence")
.catch(reportEvidenceError);
}
export function openUpliftEvidenceInNewTab(row: UpliftQueueItem): void {
// Open the tab inside the click handler; a tab opened after the fetch resolves
// is treated as an unsolicited popup and blocked.
const tab = window.open("about:blank", "_blank");
if (tab != null) {
tab.opener = null;
}
void upliftsApi
.openEvidence(row.id, tab, row.evidenceFileName || "uplift-evidence")
.catch((error: unknown) => {
toast.error(
error instanceof Error ? error.message : "Unable to download evidence right now.",
);
tab?.close();
reportEvidenceError(error);
});
}

View file

@ -1,4 +1,5 @@
import { useMemo, type ReactNode } from "react";
import CloseIcon from "@mui/icons-material/Close";
import {
Box,
Button,
@ -7,21 +8,30 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Divider,
IconButton,
Tooltip,
} from "@mui/material";
import {
formatUpliftCalendarDate,
formatUpliftDateTime,
} from "@/app/(protected)/uplifts/_components/format-uplift-dates";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { Text } from "@/components/ui/text";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useWorkOrderUplifts } from "@/domain/work-orders/use-cases/use-work-order-uplifts";
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
import { isWorkOrderUpliftsReadOnly } from "@/domain/work-orders/utils/uplift-display-utils";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
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";
const UNAVAILABLE_LABEL = "Unavailable";
const BORDERED_BLOCK_SX = {
border: 1,
borderColor: "divider",
borderRadius: 1,
px: 1.75,
py: 1.25,
} as const;
type ModalCallbacks = {
onClose: () => void;
@ -31,24 +41,122 @@ type ModalCallbacks = {
onOpenAttachment: (row: UpliftQueueItem) => void;
};
type ExposureBreakdown = { auto: number | null; admin: number | null; total: number | null };
function DetailField({ label, children }: { label: string; children: ReactNode }) {
return (
<Box>
<Text variant="label" tone="muted" className="block">
{label}
</Text>
{children}
<Text variant="body">{children}</Text>
</Box>
);
}
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
function SubsectionTitle({ children }: { children: ReactNode }) {
return (
<Box>
<Text variant="heading" className="mb-1 block">
{title}
<Text variant="label" as="h4" className="mb-1.5 mt-4 block">
{children}
</Text>
);
}
function vendorTechnicianLabel(item: UpliftQueueItem): string {
const parts = [item.vendorCompanyName.trim(), item.technicianName.trim()].filter(Boolean);
const unique = parts.filter((part, index) => parts.indexOf(part) === index);
return unique.length > 0 ? unique.join(" · ") : "Not assigned";
}
function WorkOrderSection({ item }: { item: UpliftQueueItem }) {
return (
<Box component="section" aria-label="Work order">
<Text variant="heading" className="mb-2 block">
Work order
</Text>
<Box sx={{ display: "grid", gap: 1.5 }}>{children}</Box>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.75 }}>
<DetailField label="Service">{item.serviceName || "Not specified"}</DetailField>
<DetailField label="Vendor / Technician">{vendorTechnicianLabel(item)}</DetailField>
<DetailField label="Assigned Dispatcher">
{item.workOrderDispatcherName || "Unassigned"}
</DetailField>
<DetailField label="Scheduled">
{formatUpliftCalendarDate(item.workOrderScheduledDate) || "Unscheduled"}
</DetailField>
</Box>
</Box>
);
}
function JustificationBlock({ notes }: { notes: string }) {
const text = notes.trim();
return (
<Box sx={{ ...BORDERED_BLOCK_SX, minHeight: 60 }} data-testid="uplift-justification">
{text.length > 0 && <Text variant="body">{text}</Text>}
{text.length === 0 && (
<Text variant="description" tone="muted" className="italic">
No justification provided.
</Text>
)}
</Box>
);
}
function BreakdownRow({
label,
value,
strong,
unavailable,
}: {
label: string;
value: number | null;
strong: boolean;
unavailable: boolean;
}) {
const fallback = unavailable ? UNAVAILABLE_LABEL : "—";
return (
<Box
sx={{
display: "flex",
justifyContent: "space-between",
...(strong ? { borderTop: 1, borderColor: "divider", mt: 1, pt: 1 } : { py: 0.25 }),
}}
>
<Text variant={strong ? "body" : "description"} tone={strong ? "default" : "muted"}>
{label}
</Text>
<Text
variant={strong ? "body" : "description"}
className={strong ? "font-medium" : undefined}
>
{value != null ? formatUpliftMoney(value) : fallback}
</Text>
</Box>
);
}
function ApprovedOnWoBreakdown({
breakdown,
unavailable,
}: {
breakdown: ExposureBreakdown;
unavailable: boolean;
}) {
return (
<Box sx={BORDERED_BLOCK_SX}>
<BreakdownRow
label="Auto-approved (within allowance)"
value={breakdown.auto}
strong={false}
unavailable={unavailable}
/>
<BreakdownRow
label="Admin-approved"
value={breakdown.admin}
strong={false}
unavailable={unavailable}
/>
<BreakdownRow label="Total" value={breakdown.total} strong unavailable={unavailable} />
</Box>
);
}
@ -67,142 +175,61 @@ function UpliftAttachments({
</Text>
);
}
const canOpen = item.evidenceDocumentId != null;
const extraCount =
typeof item.attachmentCount === "number" && item.attachmentCount > 1
? item.attachmentCount - 1
: 0;
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, flexWrap: "wrap" }}>
<Box
data-testid="uplift-attachments"
sx={{ display: "flex", flexWrap: "nowrap", gap: 1, overflowX: "auto", pb: 0.5 }}
>
<Chip
size="small"
variant="outlined"
label={item.evidenceFileName || "Attachment"}
onClick={item.evidenceDocumentId != null ? () => onOpenAttachment(item) : undefined}
clickable={item.evidenceDocumentId != null}
title={canOpen ? "Opens in a new tab" : undefined}
onClick={canOpen ? () => onOpenAttachment(item) : undefined}
clickable={canOpen}
sx={{ flexShrink: 0, maxWidth: 176 }}
/>
{extraCount > 0 && <Chip size="small" label={`+${extraCount}`} />}
{extraCount > 0 && (
<Chip variant="outlined" label={`+${extraCount}`} sx={{ flexShrink: 0 }} />
)}
</Box>
);
}
function WorkOrderSection({
item,
info,
unavailable,
}: {
item: UpliftQueueItem;
info?: WorkOrderTableRow;
unavailable: boolean;
}) {
const statusFallback = unavailable ? UNAVAILABLE_LABEL : "—";
return (
<DetailSection title="Work order">
<DetailField label="Work order #">
<Text variant="body">{item.woNumber || "—"}</Text>
</DetailField>
<DetailField label="Site">
<Text variant="body">{info?.site || item.site || "—"}</Text>
</DetailField>
<DetailField label="Status">
<Text variant="body">{info?.status || statusFallback}</Text>
</DetailField>
<DetailField label="Scheduled">
<Text variant="body">
{info?.scheduledOn || (unavailable ? UNAVAILABLE_LABEL : "Unscheduled")}
</Text>
</DetailField>
<DetailField label="Vendor">
<Text variant="body">{item.vendorCompanyName || "—"}</Text>
</DetailField>
</DetailSection>
);
}
function RequestSection({
function UpliftRequestSection({
item,
breakdown,
breakdownUnavailable,
onOpenAttachment,
}: {
item: UpliftQueueItem;
breakdown: ExposureBreakdown;
breakdownUnavailable: boolean;
onOpenAttachment: (row: UpliftQueueItem) => void;
}) {
const waitingClass = item.requestedAt
? getWaitTimeTextClass(waitTimeColor(item.requestedAt))
: undefined;
return (
<DetailSection title="Request">
<DetailField label="Amount">
<Text variant="body">
{formatUpliftMoney(item.currentNTE ?? 0)} → {formatUpliftMoney(item.requestedNTE)}
</Text>
</DetailField>
<DetailField label="Requested by">
<Text variant="body">{item.requestedByVendorName || "—"}</Text>
</DetailField>
<DetailField label="Waiting">
<Text variant="body" className={waitingClass}>
{item.requestedAt ? timeSince(item.requestedAt) : "—"}
</Text>
</DetailField>
<DetailField label="Notes">
<Text variant="description">{item.vendorReason || "No justification provided."}</Text>
</DetailField>
<DetailField label="Attachments">
<UpliftAttachments item={item} onOpenAttachment={onOpenAttachment} />
</DetailField>
</DetailSection>
);
}
function ApprovedOnWoBreakdown({
auto,
admin,
total,
unavailable,
}: {
auto: number | null;
admin: number | null;
total: number | null;
unavailable: boolean;
}) {
const rows: Array<{ label: string; value: number | null; strong: boolean }> = [
{ label: "Auto-approved", value: auto, strong: false },
{ label: "Admin-approved", value: admin, strong: false },
{ label: "Total", value: total, strong: true },
];
return (
<Box sx={{ display: "grid", gap: 0.5 }}>
{rows.map((row) => (
<BreakdownRow key={row.label} unavailable={unavailable} {...row} />
))}
</Box>
);
}
function BreakdownRow({
label,
value,
strong,
unavailable,
}: {
label: string;
value: number | null;
strong: boolean;
unavailable: boolean;
}) {
return (
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Text
variant={strong ? "body" : "description"}
tone={strong ? "default" : "muted"}
className={strong ? "font-medium" : undefined}
>
{label}
</Text>
<Text
variant={strong ? "body" : "description"}
className={strong ? "font-medium" : undefined}
>
{value != null ? formatUpliftMoney(value) : unavailable ? UNAVAILABLE_LABEL : "—"}
<Box component="section" aria-label="Uplift request">
<Text variant="heading" className="mb-2 block">
Uplift request
</Text>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.75 }}>
<DetailField label="Requested By">
{item.requestedByVendorName || "Unknown requester"}
</DetailField>
<DetailField label="Requested At">
{formatUpliftDateTime(item.requestedAt) || "Not recorded"}
</DetailField>
</Box>
<SubsectionTitle>Justification</SubsectionTitle>
<JustificationBlock notes={item.vendorReason} />
<SubsectionTitle>Approved on WO</SubsectionTitle>
<ApprovedOnWoBreakdown breakdown={breakdown} unavailable={breakdownUnavailable} />
<SubsectionTitle>Attachments</SubsectionTitle>
<UpliftAttachments item={item} onOpenAttachment={onOpenAttachment} />
</Box>
);
}
@ -223,6 +250,19 @@ function PendingModalActions({
const tooltip = canDecide ? "" : `Requires Tier ${item.requiredTier} role`;
return (
<>
<Tooltip title={tooltip}>
<span>
<Button
size="small"
variant="outlined"
color="error"
disabled={!canDecide}
onClick={() => onReject(item)}
>
Reject
</Button>
</span>
</Tooltip>
<Tooltip title={tooltip}>
<span>
<Button
@ -236,73 +276,61 @@ function PendingModalActions({
</Button>
</span>
</Tooltip>
<Tooltip title={tooltip}>
<span>
<Button
size="small"
variant="contained"
color="error"
disabled={!canDecide}
onClick={() => onReject(item)}
>
Reject
</Button>
</span>
</Tooltip>
</>
);
}
function UpliftModalActions({
function RevokeModalAction({
item,
closed,
canRevoke,
approvePending,
revokePending,
onApprove,
onReject,
onRevoke,
}: {
item: UpliftQueueItem;
closed: boolean;
canRevoke: boolean;
approvePending: boolean;
revokePending: boolean;
onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void;
onRevoke: (row: UpliftQueueItem) => void;
}) {
const closed = item.workOrderClosed === true;
const revokeTooltip = !canRevoke ? ADMIN_ONLY_REVOKE_TOOLTIP : closed ? CLOSED_WO_TOOLTIP : "";
return (
<>
{item.status === "Pending" && (
<PendingModalActions
item={item}
approvePending={approvePending}
onApprove={onApprove}
onReject={onReject}
/>
)}
{item.status === "Approved" && (
<Tooltip title={revokeTooltip}>
<span>
<Button
size="small"
variant="outlined"
color="error"
disabled={!canRevoke || closed || item.workOrderId == null || revokePending}
onClick={() => onRevoke(item)}
>
Revoke
</Button>
</span>
</Tooltip>
)}
</>
<Tooltip title={revokeTooltip}>
<span>
<Button
size="small"
variant="outlined"
color="error"
disabled={!canRevoke || closed || item.workOrderId == null || revokePending}
onClick={() => onRevoke(item)}
>
Revoke
</Button>
</span>
</Tooltip>
);
}
export function UpliftDetailModal({
function useExposureBreakdown(item: UpliftQueueItem) {
// Permission failures (e.g. 403 for account-scoped users) are handled quietly:
// the breakdown falls back to "Unavailable" instead of toasting.
const woUpliftsQuery = useWorkOrderUplifts(item.workOrderId ?? null, {
suppressErrorToast: true,
});
const breakdown = useMemo<ExposureBreakdown>(() => {
const woUplifts = woUpliftsQuery.data ?? [];
const autoFallback = sumUpliftAmounts(woUplifts, "auto_approved");
const adminFallback = sumUpliftAmounts(woUplifts, "approved");
const hasWoData = woUplifts.length > 0;
return {
auto: item.approvedOnWoAuto ?? (hasWoData ? autoFallback : null),
admin: item.approvedOnWoAdmin ?? (hasWoData ? adminFallback : null),
total: item.approvedOnWoTotal ?? (hasWoData ? autoFallback + adminFallback : null),
};
}, [item.approvedOnWoAuto, item.approvedOnWoAdmin, item.approvedOnWoTotal, woUpliftsQuery.data]);
return { breakdown, unavailable: woUpliftsQuery.isError };
}
function UpliftDetailContent({
item,
canRevoke,
approvePending,
@ -312,98 +340,102 @@ export function UpliftDetailModal({
onReject,
onRevoke,
onOpenAttachment,
}: {
item: UpliftQueueItem;
canRevoke: boolean;
approvePending: boolean;
revokePending: boolean;
} & ModalCallbacks) {
const { breakdown, unavailable } = useExposureBreakdown(item);
const isPending = item.status === "Pending";
const isApproved = item.status === "Approved";
const subtitle = [item.site, item.status].filter(Boolean).join(" · ");
return (
<>
<DialogTitle sx={{ pr: 6 }}>
<Text variant="title" as="div">
Uplift — WO #{item.woNumber || item.dispatchNumber || "—"}
</Text>
<Text variant="description" tone="muted" when={subtitle.length > 0}>
{subtitle}
</Text>
<IconButton
aria-label="Close"
onClick={onClose}
size="small"
sx={{ position: "absolute", top: 12, right: 12 }}
>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
bgcolor: "action.hover",
borderRadius: 1,
px: 2,
py: 1.5,
mb: 2,
}}
>
<Text variant="label" tone="muted">
Amount
</Text>
<Text variant="heading" as="span" tone="warning">
{formatUpliftMoney(item.requestedNTE)}
</Text>
</Box>
<WorkOrderSection item={item} />
<Divider sx={{ my: 2.5 }} />
<UpliftRequestSection
item={item}
breakdown={breakdown}
breakdownUnavailable={unavailable}
onOpenAttachment={onOpenAttachment}
/>
</DialogContent>
{(isPending || isApproved) && (
<DialogActions sx={{ px: 2.5, py: 1.5 }}>
{isPending && (
<PendingModalActions
item={item}
approvePending={approvePending}
onApprove={onApprove}
onReject={onReject}
/>
)}
{isApproved && (
<RevokeModalAction
item={item}
canRevoke={canRevoke}
revokePending={revokePending}
onRevoke={onRevoke}
/>
)}
</DialogActions>
)}
</>
);
}
export function UpliftDetailModal({
item,
...rest
}: {
item: UpliftQueueItem | null;
canRevoke: boolean;
approvePending: boolean;
revokePending: boolean;
} & ModalCallbacks) {
const open = item != null;
// Permission failures (e.g. 403 for account-scoped users) are handled quietly
// in this modal: dependent sections fall back to "Unavailable" instead of toasting.
const quietMeta = { suppressErrorToast: true } as const;
const boardDetail = useWorkOrderBoardDetail(
item != null && item.workOrderId != null ? item.workOrderId : undefined,
true,
quietMeta,
);
const woUpliftsQuery = useWorkOrderUplifts(
item != null && item.workOrderId != null ? item.workOrderId : null,
quietMeta,
);
const info = boardDetail.data?.info;
const closed = useMemo(() => {
if (item?.workOrderClosed === true) return true;
return info ? isWorkOrderUpliftsReadOnly(info.status) : false;
}, [item?.workOrderClosed, info]);
const breakdown = useMemo(() => {
const woUplifts = woUpliftsQuery.data ?? [];
const autoFallback = sumUpliftAmounts(woUplifts, "auto_approved");
const adminFallback = sumUpliftAmounts(woUplifts, "approved");
const hasWoData = woUplifts.length > 0;
return {
auto: item?.approvedOnWoAuto ?? (hasWoData ? autoFallback : null),
admin: item?.approvedOnWoAdmin ?? (hasWoData ? adminFallback : null),
total: item?.approvedOnWoTotal ?? (hasWoData ? autoFallback + adminFallback : null),
};
}, [
item?.approvedOnWoAuto,
item?.approvedOnWoAdmin,
item?.approvedOnWoTotal,
woUpliftsQuery.data,
]);
if (!open) {
if (item == null) {
return null;
}
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
aria-label="Uplift request details"
>
<DialogTitle>
<Text variant="title" as="div">
Uplift request
</Text>
<Text variant="description" tone="muted">
{item.woNumber || item.dispatchNumber || "Work order"} · {item.status}
</Text>
</DialogTitle>
<DialogContent>
<Box sx={{ display: "grid", gap: 3 }}>
<WorkOrderSection item={item} info={info} unavailable={boardDetail.isError} />
<RequestSection item={item} onOpenAttachment={onOpenAttachment} />
<DetailSection title="Approved on work order">
<ApprovedOnWoBreakdown
auto={breakdown.auto}
admin={breakdown.admin}
total={breakdown.total}
unavailable={woUpliftsQuery.isError}
/>
</DetailSection>
</Box>
</DialogContent>
<DialogActions>
<Button size="small" variant="outlined" onClick={onClose}>
Close
</Button>
<UpliftModalActions
item={item}
closed={closed}
canRevoke={canRevoke}
approvePending={approvePending}
revokePending={revokePending}
onApprove={onApprove}
onReject={onReject}
onRevoke={onRevoke}
/>
</DialogActions>
<Dialog open onClose={rest.onClose} maxWidth="sm" fullWidth aria-label="Uplift request details">
<UpliftDetailContent item={item} {...rest} />
</Dialog>
);
}

View file

@ -6,7 +6,10 @@ import {
} 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 { openUpliftEvidence } from "@/app/(protected)/uplifts/_components/open-uplift-evidence";
import {
openUpliftEvidence,
openUpliftEvidenceInNewTab,
} from "@/app/(protected)/uplifts/_components/open-uplift-evidence";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { isAdminUser } from "@/lib/auth/user-utils";
import { useAuthContext } from "@/providers/auth-context";
@ -181,7 +184,7 @@ export default function UpliftQueuePage() {
onApprove={handleApprove}
onReject={handleRejectRequest}
onRevoke={handleRevokeRequest}
onOpenAttachment={openUpliftEvidence}
onOpenAttachment={openUpliftEvidenceInNewTab}
/>
)}
<UpliftDecisionDialogs controller={controller} />

View file

@ -1,3 +1,4 @@
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useMemo, useState } from "react";
import type {
WorkOrderTablePatch,
@ -19,6 +20,8 @@ function isSlideOverInfoLocked(status: string | null | undefined): boolean {
return isWorkOrderFullyLocked(status) || isWorkOrderCoreLocked(status);
}
type StateSetter<T> = Dispatch<SetStateAction<T>>;
type UseSlideOverEditStateArgs = {
row: WorkOrderTableRow | null;
editMode: boolean;
@ -27,26 +30,33 @@ type UseSlideOverEditStateArgs = {
onClose: () => void;
closeDisabled: boolean;
setTab: (tab: SlideOverTab) => void;
/** Tab to land on when a work order opens outside edit mode. */
initialTab?: SlideOverTab;
/** Changes for each explicit open request, including repeated requests for the same row/tab. */
openRequestKey?: number;
};
export function useSlideOverEditState({
function useSlideOverInitialization({
row,
editMode,
infoSource,
onSave,
onClose,
closeDisabled,
initialTab,
setTab,
}: UseSlideOverEditStateArgs) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
/** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null);
const [showUnsaved, setShowUnsaved] = useState(false);
const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null);
// Reset draft/baseline only when the opened WO identity or editMode intent changes —
// not on every optimistic parent-row field update during a failed/in-flight save.
setDraft,
setBaseline,
setEditing,
setShowUnsaved,
setUnsavedIntent,
}: {
row: WorkOrderTableRow | null;
editMode: boolean;
initialTab: SlideOverTab;
setTab: (tab: SlideOverTab) => void;
setDraft: StateSetter<WorkOrderTableRow | null>;
setBaseline: StateSetter<WorkOrderTableRow | null>;
setEditing: StateSetter<boolean>;
setShowUnsaved: StateSetter<boolean>;
setUnsavedIntent: StateSetter<"close" | "cancel-edit" | null>;
}) {
useEffect(() => {
if (!row) {
setDraft(null);
@ -59,12 +69,50 @@ export function useSlideOverEditState({
setDraft(row);
setBaseline(row);
setEditing(editMode && !isSlideOverInfoLocked(row.status));
setTab("info");
setTab(editMode ? "info" : initialTab);
setShowUnsaved(false);
setUnsavedIntent(null);
// Intentionally identity-only: depending on `row` would absorb optimistic patches.
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
}, [row?.id, editMode]);
}
export function useSlideOverEditState({
row,
editMode,
infoSource,
onSave,
onClose,
closeDisabled,
setTab,
initialTab = "info",
openRequestKey = 0,
}: UseSlideOverEditStateArgs) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
/** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null);
const [showUnsaved, setShowUnsaved] = useState(false);
const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null);
// Reset draft/baseline only when the opened WO identity or editMode intent changes —
// not on every optimistic parent-row field update during a failed/in-flight save.
useSlideOverInitialization({
row,
editMode,
initialTab,
setTab,
setDraft,
setBaseline,
setEditing,
setShowUnsaved,
setUnsavedIntent,
});
useEffect(() => {
if (row?.id == null || editMode) return;
setTab(initialTab);
}, [editMode, initialTab, openRequestKey, row?.id, setTab]);
const lockStatus = infoSource?.status ?? row?.status;
const isInfoLocked = isSlideOverInfoLocked(lockStatus);
@ -95,8 +143,7 @@ export function useSlideOverEditState({
});
}, [infoSource, editing, baseline]);
const activeDraft = draft ?? infoSource;
const dirtyBaseline = baseline ?? infoSource;
const [activeDraft, dirtyBaseline] = [draft ?? infoSource, baseline ?? infoSource];
const isDirty = useMemo(() => {
if (!dirtyBaseline || !activeDraft || !editing) return false;
return isSlideOverDraftDirty(activeDraft, dirtyBaseline);

View file

@ -34,6 +34,8 @@ type UseWorkOrderSlideOverArgs = {
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
onClose: () => void;
saving?: boolean;
initialTab?: SlideOverTab;
openRequestKey?: number;
};
type CompletionUploadMutate = (input: { file: File; workOrderVersion: string }) => void;
@ -90,6 +92,8 @@ export function useWorkOrderSlideOver({
onSave,
onClose,
saving,
initialTab,
openRequestKey,
}: UseWorkOrderSlideOverArgs) {
const { user } = useAuthContext();
const workOrderId = row?.id;
@ -138,6 +142,8 @@ export function useWorkOrderSlideOver({
onClose,
closeDisabled,
setTab,
initialTab,
openRequestKey,
});
const uploadCompletionPdf = (file: File) => {

View file

@ -11,7 +11,7 @@ import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderSlideOver } from "./use-work-order-slide-over";
import { useWorkOrderSlideOver, type SlideOverTab } from "./use-work-order-slide-over";
import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav";
import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
@ -35,6 +35,8 @@ type WorkOrderSlideOverProps = {
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onClose: () => void;
saving?: boolean;
initialTab?: SlideOverTab;
openRequestKey?: number;
};
export function WorkOrderSlideOver({
@ -52,8 +54,18 @@ export function WorkOrderSlideOver({
onDuplicateFound,
onClose,
saving,
initialTab,
openRequestKey,
}: WorkOrderSlideOverProps) {
const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving });
const state = useWorkOrderSlideOver({
row,
editMode,
onSave,
onClose,
saving,
initialTab,
openRequestKey,
});
const { infoSource, activeDraft } = state;
return (

View file

@ -51,6 +51,8 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
<WorkOrderSlideOver
row={page.activeSlideOverRow}
editMode={page.slideOverEdit}
initialTab={page.slideOverTab}
openRequestKey={page.slideOverOpenRequestKey}
users={users}
sites={locations}
vendors={vendors}

View file

@ -18,17 +18,19 @@ export function useDashboardDrilldownFilters(
}, [onApply]);
useEffect(() => {
const drilldown = parseDashboardDrilldownParams(searchParams);
if (!drilldown) {
// The applied params were cleared; a later drilldown link on this mounted page applies too.
appliedRef.current = false;
return;
}
if (appliedRef.current) {
return;
}
const drilldown = parseDashboardDrilldownParams(searchParams);
if (!drilldown) {
return;
}
appliedRef.current = true;
setFromDashboard(true);
setFromDashboard(searchParams.get("fromDashboard") === "1");
onApplyRef.current(drilldown);
setSearchParams({}, { replace: true });
}, [searchParams, setSearchParams]);

View file

@ -0,0 +1,42 @@
import { useEffect, useRef } from "react";
import { useSearchParams } from "react-router";
import type { SlideOverTab } from "@/app/(protected)/workorders/_components/detail/use-work-order-slide-over";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
parseWorkOrderDeepLink,
withoutWorkOrderDeepLink,
} from "@/domain/work-orders/utils/work-order-deep-link";
type OpenSlideOver = (row: WorkOrderTableRow, edit: boolean, tab: SlideOverTab) => void;
/**
* Opens `?wo=<id>&tab=<tab>` in the slide-over once that work order's detail loads, whatever week
* the board is showing, then removes the params so closing the slide-over does not reopen it.
*/
export function useWorkOrderDeepLink(onOpen: OpenSlideOver): void {
const [searchParams, setSearchParams] = useSearchParams();
const link = parseWorkOrderDeepLink(searchParams);
const workOrderId = link?.workOrderId;
const tab = link?.tab ?? "info";
const { data, error } = useWorkOrderBoardDetail(workOrderId, workOrderId != null);
const onOpenRef = useRef(onOpen);
useEffect(() => {
onOpenRef.current = onOpen;
}, [onOpen]);
useEffect(() => {
if (workOrderId == null) {
return;
}
const loaded = data != null && String(data.info.id) === workOrderId;
if (!loaded && error == null) {
return;
}
if (loaded) {
onOpenRef.current(data.info, false, tab);
}
setSearchParams(withoutWorkOrderDeepLink(searchParams), { replace: true });
}, [workOrderId, tab, data, error, searchParams, setSearchParams]);
}

View file

@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { SlideOverTab } from "@/app/(protected)/workorders/_components/detail/use-work-order-slide-over";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
useWorkOrderLocations,
@ -17,10 +18,45 @@ import { guardDialogClose } from "@/lib/dialog-guard";
import { useWorkOrdersListFilters } from "./use-work-orders-list-filters";
import { useWorkOrdersTableData } from "./use-work-orders-table-data";
export function useWorkOrdersListPage() {
const [wizardOpen, setWizardOpen] = useState(false);
function useSlideOverOpenState() {
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
const [slideOverEdit, setSlideOverEdit] = useState(false);
const [slideOverTab, setSlideOverTab] = useState<SlideOverTab>("info");
const [slideOverOpenRequestKey, setSlideOverOpenRequestKey] = useState(0);
const handleOpenSlideOver = (
row: WorkOrderTableRow,
edit = false,
tab: SlideOverTab = "info",
) => {
setSlideOverRow(row);
setSlideOverEdit(edit);
setSlideOverTab(tab);
setSlideOverOpenRequestKey((key) => key + 1);
};
return {
slideOverRow,
setSlideOverRow,
slideOverEdit,
setSlideOverEdit,
slideOverTab,
slideOverOpenRequestKey,
handleOpenSlideOver,
};
}
export function useWorkOrdersListPage() {
const [wizardOpen, setWizardOpen] = useState(false);
const {
slideOverRow,
setSlideOverRow,
slideOverEdit,
setSlideOverEdit,
slideOverTab,
slideOverOpenRequestKey,
handleOpenSlideOver,
} = useSlideOverOpenState();
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null);
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
@ -74,11 +110,6 @@ export function useWorkOrdersListPage() {
return tableData.rows.find((row) => row.id === upliftsRow.id) ?? upliftsRow;
}, [upliftsRow, tableData.rows]);
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
setSlideOverRow(row);
setSlideOverEdit(edit);
};
const handleCloseSlideOver = () => {
setSlideOverRow(null);
setSlideOverEdit(false);
@ -130,6 +161,8 @@ export function useWorkOrdersListPage() {
wizardOpen,
setWizardOpen,
slideOverEdit,
slideOverTab,
slideOverOpenRequestKey,
confirmCancel,
setConfirmCancel,
confirmComplete,

View file

@ -10,6 +10,7 @@ import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_componen
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters";
import { useWorkOrderDeepLink } from "@/app/(protected)/workorders/_hooks/use-work-order-deep-link";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
import { useAuthContext } from "@/providers/auth-context";
@ -27,6 +28,7 @@ export default function WorkOrdersListPage() {
const page = useWorkOrdersListPage();
const { filters, tableData } = page;
const fromDashboard = useDashboardDrilldownFilters(filters.applyAdvancedFilters);
useWorkOrderDeepLink(page.handleOpenSlideOver);
return (
<Box className="flex h-full flex-col overflow-hidden p-2.5">

View file

@ -0,0 +1,189 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import {
Box,
Divider,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Typography,
} from "@mui/material";
import { ChevronDown } from "lucide-react";
import { useAuthContext } from "@/providers/auth-context";
import { getPrimaryUserRole } from "@/lib/auth/user-utils";
type UserMenuHeaderProps = {
initials: string;
fullname: string;
email: string;
};
function UserMenuHeader({ initials, fullname, email }: UserMenuHeaderProps) {
return (
<Box className="flex items-center gap-3 px-4 py-3">
<Box
className="flex shrink-0 items-center justify-center"
sx={{
width: 40,
height: 40,
borderRadius: "50%",
background: "linear-gradient(135deg, var(--accent), var(--primary))",
fontSize: "0.875rem",
fontWeight: 600,
color: "#ffffff",
border: "2px solid var(--color-header-avatar-ring)",
}}
>
{initials}
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{fullname}
</Typography>
<Typography variant="caption" color="text.secondary">
{email}
</Typography>
</Box>
</Box>
);
}
export function AppTopbarUserMenu() {
const navigate = useNavigate();
const { user, logout } = useAuthContext();
const [userAnchor, setUserAnchor] = useState<null | HTMLElement>(null);
const handleLogout = () => {
setUserAnchor(null);
logout();
};
const userInitials = user?.fullname
? user.fullname
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase()
: "U";
const userRole = getPrimaryUserRole(user?.userRoles);
return (
<>
<Box
component="button"
type="button"
aria-haspopup="true"
aria-expanded={Boolean(userAnchor)}
onClick={(event) => setUserAnchor(event.currentTarget)}
sx={{
display: "flex",
alignItems: "center",
flexShrink: 0,
gap: "10px",
py: "5px",
pl: "6px",
pr: "10px",
borderRadius: "9999px",
border: "none",
cursor: "pointer",
backgroundColor: "transparent",
color: "var(--color-header-text)",
transition: "background-color var(--duration-fast, 150ms) var(--ease-default, ease)",
"&:hover": {
backgroundColor: "var(--color-header-chip-hover)",
},
}}
>
<Box
className="flex shrink-0 items-center justify-center"
sx={{
width: 34,
height: 34,
borderRadius: "50%",
background: "linear-gradient(135deg, var(--accent), var(--primary))",
boxShadow: "0 0 0 2px var(--color-header-avatar-ring)",
fontFamily: "var(--font-display)",
fontSize: 12,
fontWeight: 700,
color: "#ffffff",
}}
>
{userInitials}
</Box>
<Box className="text-left leading-tight">
<Typography
component="p"
sx={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 600,
color: "#ffffff",
lineHeight: 1.25,
}}
>
{user?.fullname || "User"}
</Typography>
<Typography
component="p"
sx={{
fontSize: 11,
color: "var(--color-header-text-muted)",
lineHeight: 1.25,
}}
>
{userRole}
</Typography>
</Box>
<ChevronDown
size={15}
className="shrink-0"
style={{ color: "var(--color-header-text-muted)" }}
/>
</Box>
<Menu
anchorEl={userAnchor}
open={Boolean(userAnchor)}
onClose={() => setUserAnchor(null)}
transformOrigin={{ horizontal: "right", vertical: "top" }}
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
slotProps={{
paper: {
sx: { width: 260, mt: 1, boxShadow: "var(--shadow-lg)" },
},
}}
>
<UserMenuHeader
initials={userInitials}
fullname={user?.fullname || "User"}
email={user?.email || ""}
/>
<Divider />
<MenuItem
onClick={() => {
setUserAnchor(null);
navigate("/settings/dropdowns");
}}
>
<ListItemIcon>
<SettingsOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Settings</ListItemText>
</MenuItem>
<MenuItem onClick={handleLogout} sx={{ color: "error.main" }}>
<ListItemIcon>
<LogoutOutlinedIcon fontSize="small" color="error" />
</ListItemIcon>
<ListItemText>Logout</ListItemText>
</MenuItem>
</Menu>
</>
);
}

View file

@ -1,49 +1,14 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import {
AppBar,
Box,
Divider,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Toolbar,
Typography,
} from "@mui/material";
import { ChevronDown, Menu as MenuIcon } from "lucide-react";
import { AppBar, Box, Toolbar } from "@mui/material";
import { Menu as MenuIcon } from "lucide-react";
import { useAuthContext } from "@/providers/auth-context";
import { getPrimaryUserRole } from "@/lib/auth/user-utils";
import { AppTopbarUserMenu } from "@/components/layout/app-topbar-user-menu";
import { NotificationBell } from "@/components/notifications/notification-bell";
type AppTopbarProps = {
onToggleNav: () => void;
};
export function AppTopbar({ onToggleNav }: AppTopbarProps) {
const navigate = useNavigate();
const { user, logout } = useAuthContext();
const [userAnchor, setUserAnchor] = useState<null | HTMLElement>(null);
const handleLogout = () => {
setUserAnchor(null);
logout();
};
const userInitials = user?.fullname
? user.fullname
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase()
: "U";
const userRole = getPrimaryUserRole(user?.userRoles);
return (
<AppBar
position="sticky"
@ -97,135 +62,10 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
<Box sx={{ flex: 1 }} />
<Box
component="button"
type="button"
aria-haspopup="true"
aria-expanded={Boolean(userAnchor)}
onClick={(event) => setUserAnchor(event.currentTarget)}
sx={{
display: "flex",
alignItems: "center",
flexShrink: 0,
gap: "10px",
py: "5px",
pl: "6px",
pr: "10px",
borderRadius: "9999px",
border: "none",
cursor: "pointer",
backgroundColor: "transparent",
color: "var(--color-header-text)",
transition: "background-color var(--duration-fast, 150ms) var(--ease-default, ease)",
"&:hover": {
backgroundColor: "var(--color-header-chip-hover)",
},
}}
>
<Box
className="flex shrink-0 items-center justify-center"
sx={{
width: 34,
height: 34,
borderRadius: "50%",
background: "linear-gradient(135deg, var(--accent), var(--primary))",
boxShadow: "0 0 0 2px var(--color-header-avatar-ring)",
fontFamily: "var(--font-display)",
fontSize: 12,
fontWeight: 700,
color: "#ffffff",
}}
>
{userInitials}
</Box>
<Box className="text-left leading-tight">
<Typography
component="p"
sx={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 600,
color: "#ffffff",
lineHeight: 1.25,
}}
>
{user?.fullname || "User"}
</Typography>
<Typography
component="p"
sx={{
fontSize: 11,
color: "var(--color-header-text-muted)",
lineHeight: 1.25,
}}
>
{userRole}
</Typography>
</Box>
<ChevronDown
size={15}
className="shrink-0"
style={{ color: "var(--color-header-text-muted)" }}
/>
</Box>
</Toolbar>
<NotificationBell />
<Menu
anchorEl={userAnchor}
open={Boolean(userAnchor)}
onClose={() => setUserAnchor(null)}
transformOrigin={{ horizontal: "right", vertical: "top" }}
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
slotProps={{
paper: {
sx: { width: 260, mt: 1, boxShadow: "var(--shadow-lg)" },
},
}}
>
<Box className="flex items-center gap-3 px-4 py-3">
<Box
className="flex shrink-0 items-center justify-center"
sx={{
width: 40,
height: 40,
borderRadius: "50%",
background: "linear-gradient(135deg, var(--accent), var(--primary))",
fontSize: "0.875rem",
fontWeight: 600,
color: "#ffffff",
border: "2px solid var(--color-header-avatar-ring)",
}}
>
{userInitials}
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{user?.fullname || "User"}
</Typography>
<Typography variant="caption" color="text.secondary">
{user?.email || ""}
</Typography>
</Box>
</Box>
<Divider />
<MenuItem
onClick={() => {
setUserAnchor(null);
navigate("/settings/dropdowns");
}}
>
<ListItemIcon>
<SettingsOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Settings</ListItemText>
</MenuItem>
<MenuItem onClick={handleLogout} sx={{ color: "error.main" }}>
<ListItemIcon>
<LogoutOutlinedIcon fontSize="small" color="error" />
</ListItemIcon>
<ListItemText>Logout</ListItemText>
</MenuItem>
</Menu>
<AppTopbarUserMenu />
</Toolbar>
</AppBar>
);
}

View file

@ -0,0 +1,98 @@
import { useState } from "react";
import { Box, Drawer } from "@mui/material";
import { Bell, X } from "lucide-react";
import { Text } from "@/components/ui/text";
import { NotificationFeedActions } from "./notification-feed-actions";
import { NotificationFeedList } from "./notification-feed-list";
import { useNotificationCenter } from "./use-notification-center";
export function NotificationBell() {
const [open, setOpen] = useState(false);
const center = useNotificationCenter(() => setOpen(false));
const unreadCount = center.unreadIds.size;
return (
<>
<Box
component="button"
type="button"
onClick={() => setOpen(true)}
title="Notifications"
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
sx={{
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
width: 38,
height: 38,
borderRadius: "6px",
border: "none",
cursor: "pointer",
overflow: "visible",
backgroundColor: "var(--color-header-surface)",
color: "var(--color-header-icon)",
"&:hover": { backgroundColor: "var(--color-header-surface-hover)" },
}}
>
<Bell size={18} aria-hidden />
{unreadCount > 0 && (
<span
aria-hidden
className="flex items-center justify-center"
style={{
position: "absolute",
top: 2,
right: 2,
minWidth: 16,
height: 16,
padding: "0 4px",
borderRadius: 9999,
background: "var(--destructive)",
color: "var(--destructive-foreground)",
fontSize: 10,
fontWeight: 600,
lineHeight: 1,
}}
>
{unreadCount}
</span>
)}
</Box>
<Drawer
anchor="right"
open={open}
onClose={() => setOpen(false)}
slotProps={{ paper: { sx: { width: 420, background: "var(--color-bg-surface)" } } }}
>
<div
className="flex shrink-0 items-center gap-2"
style={{ padding: "16px 20px", borderBottom: "1px solid var(--color-border)" }}
>
<Text
as="h2"
variant="title"
sx={{ flex: 1, fontSize: 18, fontWeight: 700, color: "var(--color-text-primary)" }}
>
Notifications
</Text>
<NotificationFeedActions center={center} />
<button
type="button"
onClick={() => setOpen(false)}
title="Close"
aria-label="Close notifications"
className="shrink-0 cursor-pointer transition-opacity hover:opacity-70"
style={{ color: "var(--color-text-muted)" }}
>
<X size={16} aria-hidden />
</button>
</div>
<div className="flex-1 overflow-y-auto" style={{ padding: "8px 20px 20px" }}>
<NotificationFeedList center={center} />
</div>
</Drawer>
</>
);
}

View file

@ -0,0 +1,33 @@
import type { NotificationCenter } from "./use-notification-center";
type NotificationFeedActionsProps = {
center: NotificationCenter;
};
export function NotificationFeedActions({ center }: NotificationFeedActionsProps) {
const hasUnread = center.unreadIds.size > 0;
const hasItems = center.sections.length > 0;
return (
<>
<button
type="button"
onClick={center.markAllRead}
disabled={!hasUnread}
className="cursor-pointer transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={{ fontSize: 12, fontWeight: 500, color: "var(--color-primary)" }}
>
Mark all read
</button>
<button
type="button"
onClick={center.dismissAll}
disabled={!hasItems}
className="cursor-pointer transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={{ fontSize: 12, fontWeight: 500, color: "var(--color-text-muted)" }}
>
Clear all
</button>
</>
);
}

View file

@ -0,0 +1,89 @@
import { CircularProgress } from "@mui/material";
import { Inbox } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { NotificationCenter } from "./use-notification-center";
import { NotificationRow } from "./notification-row";
type NotificationFeedListProps = {
center: NotificationCenter;
};
export function NotificationFeedList({ center }: NotificationFeedListProps) {
if (center.isLoading) {
return (
<div className="flex justify-center py-16">
<CircularProgress size={24} aria-label="Loading notifications" />
</div>
);
}
if (center.error != null && !center.hasData) {
return (
<Text variant="error" className="py-6">
Notifications could not be loaded. They will retry automatically.
</Text>
);
}
if (center.sections.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-16">
<Inbox size={24} aria-hidden style={{ color: "var(--color-text-muted)" }} />
<Text variant="description" sx={{ fontSize: 13, color: "var(--color-text-muted)" }}>
Nothing needs your attention
</Text>
</div>
);
}
return (
<div>
{center.sections.map((section) => (
<section
key={section.reason}
aria-label={`${section.label}, ${section.count}`}
style={{ marginTop: 16 }}
>
<div className="flex items-center gap-2" style={{ marginBottom: 4 }}>
<Text
as="h3"
variant="label"
sx={{
fontSize: 11,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.06em",
color: "var(--color-text-muted)",
}}
>
{section.label}
</Text>
<span
style={{
fontSize: 11,
fontWeight: 500,
color: "var(--color-text-secondary)",
background: "var(--color-bg-muted)",
borderRadius: "var(--radius-sm)",
padding: "1px 6px",
}}
>
{section.count}
</span>
</div>
<ul className="m-0 list-none p-0">
{section.items.map((item) => (
<NotificationRow
key={item.id}
item={item}
unread={center.unreadIds.has(item.id)}
onOpen={center.open}
onDismiss={center.dismiss}
/>
))}
</ul>
</section>
))}
</div>
);
}

View file

@ -0,0 +1,127 @@
import type { LucideIcon } from "lucide-react";
import {
AlertTriangle,
Bell,
Clock,
Inbox,
MessageSquare,
ShieldAlert,
Wrench,
X,
} from "lucide-react";
import { Link } from "react-router";
import { Text } from "@/components/ui/text";
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
import { workOrderViewUrl } from "@/domain/notifications/utils/notification-target-url";
import { isDismissable } from "@/domain/notifications/utils/visible-notification-sections";
const REASON_ICONS: Record<string, LucideIcon> = {
"severity-critical": AlertTriangle,
sla: AlertTriangle,
mentions: MessageSquare,
unassigned: Inbox,
"due-soon": Clock,
"no-vendor": Wrench,
"aveta-missing": ShieldAlert,
activity: MessageSquare,
"vendor-conflict": Wrench,
};
type NotificationRowProps = {
item: NotificationItem;
unread: boolean;
onOpen: (item: NotificationItem, url?: string) => void;
onDismiss: (item: NotificationItem) => void;
};
/** 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) {
return null;
}
return (
<span className="flex flex-wrap gap-2" style={{ paddingLeft: 31, marginTop: 2 }}>
{item.workOrders.map((workOrder) => {
const url = workOrderViewUrl(workOrder.id);
return (
<Link
key={workOrder.id}
to={url}
onClick={(event) => {
event.preventDefault();
onOpen(item, url);
}}
className="text-xs underline-offset-2 hover:underline"
style={{ color: "var(--color-primary)" }}
>
{workOrder.number != null ? `WO #${workOrder.number}` : `WO ${workOrder.id}`}
</Link>
);
})}
</span>
);
}
export function NotificationRow({ item, unread, onOpen, onDismiss }: NotificationRowProps) {
const Icon = REASON_ICONS[item.reason] ?? Bell;
return (
<li
className="group flex items-start gap-2.5 transition-colors hover:bg-[var(--color-bg-muted)]"
style={{
padding: "10px 8px",
marginInline: -8,
borderRadius: "var(--radius-md)",
borderBottom: "1px solid var(--color-bg-muted)",
}}
data-unread={unread}
>
<span className="flex min-w-0 flex-1 flex-col">
<button
type="button"
onClick={() => onOpen(item)}
className="flex min-w-0 flex-1 cursor-pointer items-start gap-2.5 text-left"
>
<span
aria-hidden
className="shrink-0 rounded-full"
style={{
width: 6,
height: 6,
marginTop: 7,
background: unread ? "var(--color-primary)" : "transparent",
}}
/>
<Icon
size={15}
aria-hidden
className="shrink-0"
style={{ color: "var(--color-text-muted)", marginTop: 2 }}
/>
<Text
as="span"
variant="description"
sx={{ fontSize: 13, lineHeight: 1.45, fontWeight: unread ? 500 : 400, margin: 0 }}
>
{unread && <span className="sr-only">Unread: </span>}
{item.title}
</Text>
</button>
<WorkOrderLinks item={item} onOpen={onOpen} />
</span>
{isDismissable(item) && (
<button
type="button"
onClick={() => onDismiss(item)}
title="Dismiss"
aria-label="Dismiss notification"
className="shrink-0 cursor-pointer opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
style={{ color: "var(--color-text-muted)", marginTop: 2 }}
>
<X size={13} aria-hidden />
</button>
)}
</li>
);
}

View file

@ -0,0 +1,47 @@
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 { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
import { notificationTargetUrl } from "@/domain/notifications/utils/notification-target-url";
import {
isDismissable,
visibleNotificationSections,
} from "@/domain/notifications/utils/visible-notification-sections";
export function useNotificationCenter(onNavigate?: () => void) {
const navigate = useNavigate();
const feed = useNotificationFeed();
const { dismissedIds, readIds, dismiss, markRead } = useNotificationSession();
const sections = useMemo(
() => visibleNotificationSections(feed.data?.sections ?? [], dismissedIds),
[feed.data, dismissedIds],
);
const items = useMemo(() => sections.flatMap((section) => section.items), [sections]);
const unreadIds = useMemo(
() => new Set(items.filter((item) => !readIds.has(item.id)).map((item) => item.id)),
[items, readIds],
);
const open = (item: NotificationItem, url = notificationTargetUrl(item)) => {
markRead([item.id]);
if (url == null) return;
onNavigate?.();
navigate(url);
};
return {
sections,
unreadIds,
isLoading: feed.isLoading,
hasData: feed.data != null,
error: feed.error,
open,
dismiss: (item: NotificationItem) => dismiss([item.id]),
dismissAll: () => dismiss(items.filter(isDismissable).map((item) => item.id)),
markAllRead: () => markRead(items.map((item) => item.id)),
};
}
export type NotificationCenter = ReturnType<typeof useNotificationCenter>;

View file

@ -14,8 +14,15 @@ export const OPEN_WIZARD_STATUSES: readonly string[] = ALL_WIZARD_STATUSES.filte
(status) => status !== "Completed",
);
export function workOrderDrilldownUrl(search: URLSearchParams): string {
const query = search.toString();
export function workOrderDrilldownUrl(
search: URLSearchParams,
source: "dashboard" | "notification" = "dashboard",
): string {
const params = new URLSearchParams(search);
if (source === "dashboard") {
params.set("fromDashboard", "1");
}
const query = params.toString();
return query ? `${WORK_ORDERS_ROUTE}?${query}` : WORK_ORDERS_ROUTE;
}

View file

@ -0,0 +1,10 @@
import { apiGet } from "@/api/api";
import {
notificationFeedSchema,
type NotificationFeed,
} from "@/domain/notifications/types/notification-feed";
export const notificationsApi = {
feed: async (): Promise<NotificationFeed> =>
notificationFeedSchema.parse(await apiGet<unknown>("notifications")),
};

View file

@ -0,0 +1,18 @@
import { createContext, useContext } from "react";
export type NotificationSession = {
dismissedIds: ReadonlySet<string>;
readIds: ReadonlySet<string>;
dismiss: (ids: string[]) => void;
markRead: (ids: string[]) => void;
};
export const NotificationSessionContext = createContext<NotificationSession | null>(null);
export function useNotificationSession(): NotificationSession {
const session = useContext(NotificationSessionContext);
if (!session) {
throw new Error("useNotificationSession must be used within a NotificationSessionProvider");
}
return session;
}

View file

@ -0,0 +1,38 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { NotificationSessionContext } from "./notification-session-context";
function addAll(current: ReadonlySet<string>, ids: string[]): ReadonlySet<string> {
if (ids.every((id) => current.has(id))) {
return current;
}
const next = new Set(current);
ids.forEach((id) => next.add(id));
return next;
}
/**
* Dismissed and read notification ids for this app session only. Nothing is persisted, so a
* reload starts over and a condition that still holds shows up again.
*/
export function NotificationSessionProvider({ children }: { children: ReactNode }) {
const [dismissedIds, setDismissedIds] = useState<ReadonlySet<string>>(() => new Set());
const [readIds, setReadIds] = useState<ReadonlySet<string>>(() => new Set());
const dismiss = useCallback((ids: string[]) => {
setDismissedIds((current) => addAll(current, ids));
}, []);
const markRead = useCallback((ids: string[]) => {
setReadIds((current) => addAll(current, ids));
}, []);
const value = useMemo(
() => ({ dismissedIds, readIds, dismiss, markRead }),
[dismissedIds, readIds, dismiss, markRead],
);
return (
<NotificationSessionContext.Provider value={value}>
{children}
</NotificationSessionContext.Provider>
);
}

View file

@ -0,0 +1,62 @@
import { z } from "zod";
/** Fixed reason order; it breaks ties between sections of equal severity. */
export const NOTIFICATION_REASON_ORDER = [
"severity-critical",
"sla",
"mentions",
"unassigned",
"due-soon",
"no-vendor",
"aveta-missing",
"activity",
"vendor-conflict",
] as const;
export const NOTIFICATION_SEVERITY_RANK = {
Critical: 3,
High: 2,
Medium: 1,
Low: 0,
} as const;
const severitySchema = z.enum(["Critical", "High", "Medium", "Low"]);
const targetSchema = z.object({
kind: z.enum(["workOrder", "queue"]),
workOrderId: z.number().nullish(),
tab: z.string().nullish(),
queue: z.string().nullish(),
});
const itemSchema = z.object({
id: z.string(),
reason: z.string(),
severity: severitySchema,
// "acknowledge" rows (a missed response deadline) never take the normal dismiss.
rowType: z.enum(["dismissable", "acknowledge"]),
title: z.string(),
count: z.number(),
triggeredAt: z.string(),
target: targetSchema,
workOrders: z.array(z.object({ id: z.number(), number: z.string().nullish() })),
});
const sectionSchema = z.object({
reason: z.string(),
label: z.string(),
severity: severitySchema,
count: z.number(),
items: z.array(itemSchema),
});
export const notificationFeedSchema = z.object({
generatedAt: z.string(),
sections: z.array(sectionSchema),
});
export type NotificationSeverity = z.infer<typeof severitySchema>;
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>;

View file

@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { notificationsApi } from "@/domain/notifications/api/notifications-api";
import { queryKeys } from "@/infra/query-key/query-key";
/** Live conditions are recomputed server-side on every read, so polling keeps counts current. */
export const NOTIFICATION_FEED_REFRESH_MS = 30_000;
export function useNotificationFeed() {
return useQuery({
queryKey: queryKeys.notifications.feed(),
queryFn: notificationsApi.feed,
refetchInterval: NOTIFICATION_FEED_REFRESH_MS,
// The feed shows its own error state; a toast on every 30s poll would repeat the same failure.
meta: { suppressErrorToast: true },
});
}

View file

@ -0,0 +1,46 @@
import {
OPEN_WIZARD_STATUSES,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import {
UNASSIGNED_QUEUE_DATE_FROM,
UNASSIGNED_QUEUE_DATE_TO,
} from "@/domain/work-orders/utils/board-query-params";
import { workOrderDeepLinkUrl } from "@/domain/work-orders/utils/work-order-deep-link";
/** Every open, unassigned work order in any week, through the existing Work Orders filter params. */
export function unassignedQueueUrl(): string {
const search = new URLSearchParams();
search.set("dateFrom", UNASSIGNED_QUEUE_DATE_FROM);
search.set("dateTo", UNASSIGNED_QUEUE_DATE_TO);
search.set("dispatchers", ASSIGNEE_FILTER_UNASSIGNED);
search.set("statuses", OPEN_WIZARD_STATUSES.join(","));
return workOrderDrilldownUrl(search, "notification");
}
/** The work order form with vendor assignment open — where the vendor reminders always sent "Choose vendor". */
export function chooseVendorUrl(workOrderId: number): string {
return `/workorders/edit/${workOrderId}?assignVendor=1`;
}
export function workOrderViewUrl(workOrderId: number): string {
return `/workorders/${workOrderId}`;
}
export function notificationTargetUrl(
item: Pick<NotificationItem, "reason" | "target">,
): string | null {
const { target } = item;
if (target.kind === "queue") {
return target.queue === "unassigned" ? unassignedQueueUrl() : null;
}
if (target.workOrderId == null) {
return null;
}
if (item.reason === "no-vendor") {
return chooseVendorUrl(target.workOrderId);
}
return workOrderDeepLinkUrl(target.workOrderId, target.tab);
}

View file

@ -0,0 +1,60 @@
import {
NOTIFICATION_REASON_ORDER,
NOTIFICATION_SEVERITY_RANK,
type NotificationItem,
type NotificationSection,
} from "@/domain/notifications/types/notification-feed";
const reasonOrder: ReadonlyMap<string, number> = new Map(
NOTIFICATION_REASON_ORDER.map((reason, index) => [reason, index]),
);
function reasonIndex(reason: string): number {
return reasonOrder.get(reason) ?? NOTIFICATION_REASON_ORDER.length;
}
function highestSeverity(items: NotificationItem[]): NotificationItem["severity"] {
return items.reduce<NotificationItem["severity"]>(
(highest, item) =>
NOTIFICATION_SEVERITY_RANK[item.severity] > NOTIFICATION_SEVERITY_RANK[highest]
? item.severity
: highest,
"Low",
);
}
export function isDismissable(item: NotificationItem): boolean {
return item.rowType === "dismissable";
}
/**
* Applies this session's dismissals to the server feed: dismissed rows leave, section counts drop
* by the work orders those rows represented, empty sections disappear, and sections re-sort by
* their highest remaining severity with the fixed reason order breaking ties.
*/
export function visibleNotificationSections(
sections: NotificationSection[],
dismissedIds: ReadonlySet<string>,
): NotificationSection[] {
return sections
.map((section) => {
const items = section.items.filter(
(item) => !isDismissable(item) || !dismissedIds.has(item.id),
);
const dismissedCount = section.items
.filter((item) => !items.includes(item))
.reduce((total, item) => total + item.count, 0);
return {
...section,
items,
count: Math.max(section.count - dismissedCount, 0),
severity: highestSeverity(items),
};
})
.filter((section) => section.items.length > 0)
.sort(
(left, right) =>
NOTIFICATION_SEVERITY_RANK[right.severity] - NOTIFICATION_SEVERITY_RANK[left.severity] ||
reasonIndex(left.reason) - reasonIndex(right.reason),
);
}

View file

@ -106,28 +106,61 @@ export const upliftsApi = {
id: string | number,
fallbackFileName = "uplift-evidence",
): Promise<void> => {
let response: Response;
try {
response = await apiRequestRaw(
"get",
evidenceUrl(id),
undefined,
"upliftsApi.downloadEvidence",
);
} catch (error) {
if (error instanceof HTTPError) {
throw evidenceHttpError(error.response.status);
}
throw error;
}
const response = await fetchEvidence(id, "upliftsApi.downloadEvidence");
const blob = await response.blob();
const fileName = readContentDispositionFilename(response, fallbackFileName);
const url = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(url);
saveBlob(blob, readContentDispositionFilename(response, fallbackFileName));
},
/**
* Renders the evidence in `tab`, which the caller opens synchronously on click so
* popup blockers allow it. Only inert types render inline on the app origin; any
* other type (HTML, SVG, ...) is downloaded instead and the tab is closed.
*/
openEvidence: async (
id: string | number,
tab: Window | null,
fallbackFileName = "uplift-evidence",
): Promise<void> => {
const response = await fetchEvidence(id, "upliftsApi.openEvidence");
const blob = await response.blob();
const type = blob.type.split(";")[0].trim().toLowerCase();
if (tab == null || !INLINE_EVIDENCE_TYPES.has(type)) {
tab?.close();
saveBlob(blob, readContentDispositionFilename(response, fallbackFileName));
return;
}
const url = URL.createObjectURL(new Blob([blob], { type }));
tab.location.href = url;
window.setTimeout(() => URL.revokeObjectURL(url), EVIDENCE_URL_TTL_MS);
},
};
const INLINE_EVIDENCE_TYPES = new Set([
"application/pdf",
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
]);
const EVIDENCE_URL_TTL_MS = 60_000;
async function fetchEvidence(id: string | number, operation: string): Promise<Response> {
try {
return await apiRequestRaw("get", evidenceUrl(id), undefined, operation);
} catch (error) {
if (error instanceof HTTPError) {
throw evidenceHttpError(error.response.status);
}
throw error;
}
}
function saveBlob(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(url);
}

View file

@ -116,6 +116,9 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem {
"trade",
"Trade",
),
technicianName: readString(item, "technicianName", "TechnicianName"),
workOrderDispatcherName: readString(item, "workOrderDispatcherName", "WorkOrderDispatcherName"),
workOrderScheduledDate: readString(item, "workOrderScheduledDate", "WorkOrderScheduledDate"),
attachmentCount: readNumber(
item,
"attachmentCount",

View file

@ -46,6 +46,9 @@ export interface UpliftQueueItem extends UpliftRequest {
woNumber: string;
site: string;
serviceName: string;
technicianName: string;
workOrderDispatcherName: string;
workOrderScheduledDate: string;
attachmentCount: number | null;
approvedOnWoAuto: number | null;
approvedOnWoAdmin: number | null;

View file

@ -5,7 +5,6 @@ import type {
VendorAvailability,
VendorAssignmentPayload,
VendorInsight,
VendorNotification,
} from "@/domain/vendor-operations/types/vendor-operations";
const base = "vendor-operations";
@ -43,13 +42,6 @@ async function saveDownload(path: string, fileName: string, params: InsightParam
}
export const vendorOperationsApi = {
notifications: async (): Promise<VendorNotification[]> => {
const response = await apiGet<{ items?: VendorNotification[]; Items?: VendorNotification[] }>(
`${base}/notifications`,
);
return response.items ?? response.Items ?? [];
},
availability: async (params: {
start?: string;
end?: string;

View file

@ -1,28 +1,3 @@
export interface VendorNotificationAction {
label: string;
href: string;
}
export interface VendorNotification {
id: string;
type: "NoVendor" | "VendorConflict";
severity: "Medium";
group: "No Vendor" | "Vendor Conflict";
title: string;
workOrderId?: number;
workOrderNumber?: string;
triggeredAt: string;
scheduledAt?: string;
action?: VendorNotificationAction;
actions?: VendorNotificationAction[];
workOrders?: Array<{
workOrderId: number;
workOrderNumber?: string;
start: string;
end: string;
}>;
}
export interface VendorAvailability {
id: number;
companyName: string;

View file

@ -0,0 +1,81 @@
import { API_PATHS } from "@/api/api-paths";
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
import { apiRequestRaw } from "@/api/api";
import {
BoardPatchConflictError,
BoardPatchValidationError,
} from "@/domain/work-orders/errors/board-patch-errors";
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
import type {
WorkOrderBoardPatchPayload,
WorkOrderPocPatchPayload,
} from "@/domain/work-orders/types/work-order-board";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
/** Shared request/error handling for board row PATCH endpoints (board + poc). */
async function patchBoardRow(
operationName: string,
method: "patch",
url: string,
payload: WorkOrderBoardPatchPayload | WorkOrderPocPatchPayload,
): Promise<WorkOrderTableRow> {
const response = await apiRequestRaw(
method,
url,
{ json: payload, throwHttpErrors: false },
operationName,
);
let data: unknown;
try {
data = await response.json();
} catch {
data = undefined;
}
if (response.ok) {
return mapWorkOrderTableRow(data);
}
const record = typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
if (response.status === 409) {
const currentStateRaw = record.currentState ?? record.CurrentState;
const currentState = currentStateRaw != null ? mapWorkOrderTableRow(currentStateRaw) : null;
throw new BoardPatchConflictError(
typeof record.message === "string"
? record.message
: "The record was modified by another user. Refresh and retry.",
currentState,
);
}
if (response.status === 422) {
throw new BoardPatchValidationError(
typeof record.code === "string" ? record.code : "ValidationError",
typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data),
);
}
throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
}
export const workOrderBoardPatchApi = {
patchBoardField: async (
id: string | number,
payload: WorkOrderBoardPatchPayload,
): Promise<WorkOrderTableRow> =>
patchBoardRow(
"workOrdersApi.patchBoardField",
"patch",
API_PATHS.workOrder.boardPatch(id),
payload,
),
/** Manual POC override — same row/error contract as the board patch. */
updatePoc: async (
id: string | number,
payload: WorkOrderPocPatchPayload,
): Promise<WorkOrderTableRow> =>
patchBoardRow("workOrdersApi.updatePoc", "patch", API_PATHS.workOrder.pocPatch(id), payload),
};

View file

@ -1,12 +1,8 @@
import { API_PATHS } from "@/api/api-paths";
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
import { apiDelete, apiGet, apiPost, apiPostForm, apiPut, apiRequestRaw } from "@/api/api";
import { apiDelete, apiGet, apiPost, apiPostForm, apiPut } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import {
BoardPatchConflictError,
BoardPatchValidationError,
} from "@/domain/work-orders/errors/board-patch-errors";
import { workOrderBoardDocumentsApi } from "@/domain/work-orders/api/work-order-board-documents-api";
import { workOrderBoardPatchApi } from "@/domain/work-orders/api/work-order-board-patch-api";
import {
mapDispatchComment,
mapDispatchDetail,
@ -20,7 +16,6 @@ import {
mapWorkOrderDetail,
mapWorkOrderFormToCreatePayload,
mapWorkOrderFormToUpdateFormData,
mapWorkOrderTableRow,
mapWorkOrderTableRows,
} from "@/domain/work-orders/mappers/work-order-mapper";
import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
@ -42,7 +37,6 @@ import type {
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import type {
WorkOrderBoardListResult,
WorkOrderBoardPatchPayload,
WorkOrderBoardQueryParams,
WorkOrderBoardResponseApi,
WorkOrderBoardSearchQueryParams,
@ -310,51 +304,10 @@ export const workOrdersApi = {
return mapWorkOrderDetail(handleApiResponse(data));
},
patchBoardField: async (
id: string | number,
payload: WorkOrderBoardPatchPayload,
): Promise<WorkOrderTableRow> => {
const response = await apiRequestRaw(
"patch",
API_PATHS.workOrder.boardPatch(id),
{ json: payload, throwHttpErrors: false },
"workOrdersApi.patchBoardField",
);
patchBoardField: workOrderBoardPatchApi.patchBoardField,
let data: unknown;
try {
data = await response.json();
} catch {
data = undefined;
}
if (response.ok) {
return mapWorkOrderTableRow(data);
}
const record =
typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
if (response.status === 409) {
const currentStateRaw = record.currentState ?? record.CurrentState;
const currentState = currentStateRaw != null ? mapWorkOrderTableRow(currentStateRaw) : null;
throw new BoardPatchConflictError(
typeof record.message === "string"
? record.message
: "The record was modified by another user. Refresh and retry.",
currentState,
);
}
if (response.status === 422) {
throw new BoardPatchValidationError(
typeof record.code === "string" ? record.code : "ValidationError",
typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data),
);
}
throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
},
/** Manual POC override — same row/error contract as the board patch. */
updatePoc: workOrderBoardPatchApi.updatePoc,
update: async (
id: string | number,

View file

@ -28,9 +28,6 @@ const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
"techPhone",
"location",
"locationId",
"pocName",
"pocPhone",
"pocNotes",
"title",
"priority",
"apptStart",
@ -53,16 +50,44 @@ export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {
return keys.length > 0 && keys.every((key) => LOCAL_ONLY_KEYS.has(key));
}
/** Composite manual POC override op routed to PATCH …/poc. */
export type BoardPocOp = {
kind: "poc";
pocName?: string;
pocPhone?: string;
pocNotes?: string;
};
export type BoardOp = BoardFieldOp | BoardPocOp;
function expandPocOp(patch: WorkOrderTablePatch): BoardPocOp[] {
const hasPocFields =
patch.pocName !== undefined || patch.pocPhone !== undefined || patch.pocNotes !== undefined;
if (!hasPocFields) {
return [];
}
return [
{
kind: "poc",
pocName: patch.pocName,
pocPhone: patch.pocPhone,
pocNotes: patch.pocNotes,
},
];
}
/**
* Expands a UI table patch into ordered board field ops (one PATCH each).
* Schedule/week-only fields are emitted first in a stable order.
* Expands a UI table patch into ordered board ops (one request each).
* Schedule/week-only fields are emitted first in a stable order; the composite
* POC op follows, then scalar fields.
*/
export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardFieldOp[] {
export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardOp[] {
if (isLocalOnlyTablePatch(patch)) {
return [];
}
return [...expandScheduleBoardOps(patch), ...expandScalarBoardOps(patch)];
return [...expandScheduleBoardOps(patch), ...expandPocOp(patch), ...expandScalarBoardOps(patch)];
}
export function isDispatchBoardField(field: WorkOrderBoardEditableField): boolean {

View file

@ -134,6 +134,14 @@ export interface WorkOrderBoardPatchPayload {
primaryDispatchId?: number | null;
}
/** Payload for PATCH /workorders/{id}/poc (manual POC override). */
export interface WorkOrderPocPatchPayload {
pocName: string;
pocPhone: string;
pocNotes: string;
workOrderVersion: string;
}
export type WorkOrderBoardEditableField =
| "woNumber"
| "workOrderType"

View file

@ -14,6 +14,7 @@ import {
buildBoardPatchPayload,
expandTablePatchToBoardFieldOps,
isLocalOnlyTablePatch,
type BoardOp,
} from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
import type {
@ -53,6 +54,30 @@ export type WorkOrderTablePatchHandlers = {
type PatchResult =
{ kind: "local"; patch: WorkOrderTablePatch } | { kind: "board"; row: WorkOrderTableRow };
function isPocOp(op: BoardOp): op is Extract<BoardOp, { kind: "poc" }> {
return "kind" in op && op.kind === "poc";
}
async function applyBoardOp(
id: string | number,
op: BoardOp,
row: WorkOrderTableRow,
): Promise<WorkOrderTableRow> {
if (isPocOp(op)) {
if (!row.rowVersion) {
throw new Error("workOrderVersion is required for board patch");
}
return workOrdersApi.updatePoc(id, {
pocName: op.pocName ?? row.pocName,
pocPhone: op.pocPhone ?? row.pocPhone,
pocNotes: op.pocNotes ?? row.pocNotes,
workOrderVersion: row.rowVersion,
});
}
return workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, row));
}
async function applyBoardFieldOps(
id: string | number,
patch: WorkOrderTablePatch,
@ -72,7 +97,7 @@ async function applyBoardFieldOps(
let completedOps = 0;
for (const op of ops) {
try {
latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
latest = await applyBoardOp(id, op, latest);
completedOps += 1;
} catch (error) {
if (completedOps > 0) {

View file

@ -7,7 +7,8 @@ import type {
/**
* Fields compared for dirty state and included in Save patch.
* Only keys that map to board PATCH (or required companions like dispatcherName).
* Primary POC scalars and tech phone are not board-editable — exclude so Save cannot no-op.
* Primary POC scalars persist via the dedicated POC endpoint;
* tech phone is not board-editable — exclude so Save cannot no-op.
* additionalContacts is board-editable (SH-218 PATCH field).
*/
export const SLIDE_OVER_EDIT_KEYS = [
@ -32,6 +33,9 @@ export const SLIDE_OVER_EDIT_KEYS = [
"vendorId",
// company/tech are display companions; board persists vendor via vendorId only
"status",
"pocName",
"pocPhone",
"pocNotes",
] as const satisfies ReadonlyArray<keyof WorkOrderTableRow>;
type EditKey = (typeof SLIDE_OVER_EDIT_KEYS)[number];

View file

@ -0,0 +1,48 @@
import { WORK_ORDERS_ROUTE } from "@/domain/dashboard/utils/work-order-drilldown-links";
const WORK_ORDER_PARAM = "wo";
const TAB_PARAM = "tab";
export const WORK_ORDER_DEEP_LINK_TABS = [
"info",
"comments",
"audit",
"completion",
"extras",
] as const;
export type WorkOrderDeepLinkTab = (typeof WORK_ORDER_DEEP_LINK_TABS)[number];
export type WorkOrderDeepLink = {
workOrderId: string;
tab: WorkOrderDeepLinkTab;
};
function isDeepLinkTab(value: string | null): value is WorkOrderDeepLinkTab {
return WORK_ORDER_DEEP_LINK_TABS.some((tab) => tab === value);
}
/** `/workorders?wo=<id>&tab=<tab>` opens that work order's slide-over on the given tab. */
export function workOrderDeepLinkUrl(workOrderId: string | number, tab?: string | null): string {
const search = new URLSearchParams({ [WORK_ORDER_PARAM]: String(workOrderId) });
if (isDeepLinkTab(tab ?? null)) {
search.set(TAB_PARAM, String(tab));
}
return `${WORK_ORDERS_ROUTE}?${search.toString()}`;
}
export function parseWorkOrderDeepLink(params: URLSearchParams): WorkOrderDeepLink | null {
const workOrderId = (params.get(WORK_ORDER_PARAM) ?? "").trim();
if (!/^\d+$/.test(workOrderId)) {
return null;
}
const tab = params.get(TAB_PARAM);
return { workOrderId, tab: isDeepLinkTab(tab) ? tab : "info" };
}
export function withoutWorkOrderDeepLink(params: URLSearchParams): URLSearchParams {
const next = new URLSearchParams(params);
next.delete(WORK_ORDER_PARAM);
next.delete(TAB_PARAM);
return next;
}

View file

@ -149,6 +149,10 @@ export const queryKeys = {
detail: (id: string | number) => [...queryKeys.assets.all, "detail", id] as const,
sites: () => [...queryKeys.assets.all, "sites"] as const,
},
notifications: {
all: ["notifications"] as const,
feed: () => [...queryKeys.notifications.all, "feed"] as const,
},
vendorPortal: {
all: ["vendorPortal"] as const,
session: (token: string) => [...queryKeys.vendorPortal.all, "session", token] as const,

View file

@ -80,6 +80,7 @@ import DashboardPage from "@/app/(protected)/dashboard";
import {
avetaPendingDrilldownSearch,
scheduledTomorrowDrilldownSearch,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import { addDaysIso, businessTodayIso } from "@/domain/dashboard/utils/dashboard-range-utils";
@ -161,7 +162,7 @@ describe("DashboardPage", () => {
const today = businessTodayIso();
expect(navigate).toHaveBeenCalledWith(
`/workorders?${scheduledTomorrowDrilldownSearch(today).toString()}`,
workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch(today)),
);
fireEvent.click(screen.getByRole("button", { name: /Pending Uplifts/ }));

View file

@ -41,6 +41,9 @@ const approvedItem: UpliftQueueItem = {
woNumber: "WO-55",
site: "Site B",
serviceName: "HVAC service",
technicianName: "",
workOrderDispatcherName: "",
workOrderScheduledDate: "",
attachmentCount: 3,
approvedOnWoAuto: 100,
approvedOnWoAdmin: 400,

View file

@ -1,4 +1,4 @@
import { screen, waitFor } from "@testing-library/react";
import { screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import { ApiError } from "@/api/api-error";
@ -80,6 +80,9 @@ const dispatcherItem: UpliftQueueItem = {
woNumber: "WO-99",
site: "Site A",
serviceName: "Plumbing repair",
technicianName: "Tom Tech",
workOrderDispatcherName: "Dana Ruiz",
workOrderScheduledDate: "2026-04-10T00:00:00",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
@ -113,17 +116,31 @@ describe("UpliftDetailModal permission failures (Dispatcher 403)", () => {
woUpliftsMock.calls = 0;
});
it("shows unavailable sections without an error toast and keeps Revoke admin-only", async () => {
it("renders the work order from the queue item without a work-order fetch or error toast", async () => {
renderWithGovernedClient();
await waitFor(() => {
expect(boardDetailMock.calls).toBeGreaterThan(0);
expect(woUpliftsMock.calls).toBeGreaterThan(0);
});
expect(boardDetailMock.calls).toBe(0);
const workOrder = screen.getByRole("region", { name: "Work order" });
expect(within(workOrder).getByText("Dana Ruiz")).toBeInTheDocument();
expect(within(workOrder).getByText("Gateway Plumbing · Tom Tech")).toBeInTheDocument();
expect(within(workOrder).getByText("Apr 10, 2026")).toBeInTheDocument();
expect(within(workOrder).queryByText("Unavailable")).not.toBeInTheDocument();
expect(toastMocks.error).not.toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getAllByText("Unavailable").length).toBeGreaterThanOrEqual(3);
});
it("marks the approved-on-WO breakdown unavailable without a toast and keeps Revoke admin-only", async () => {
renderWithGovernedClient();
const request = screen.getByRole("region", { name: "Uplift request" });
for (const label of ["Auto-approved (within allowance)", "Admin-approved", "Total"]) {
const row = within(request).getByText(label).parentElement as HTMLElement;
await waitFor(() => {
expect(within(row).getByText("Unavailable")).toBeInTheDocument();
});
}
const revoke = screen.getByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();

View file

@ -1,18 +1,13 @@
import { fireEvent, screen } from "@testing-library/react";
import { fireEvent, screen, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { renderWithProviders } from "@/test/test-utils";
const boardDetail = vi.hoisted(() => ({ data: null as Record<string, unknown> | null }));
const woUplifts = vi.hoisted(() => ({ data: null as Array<Record<string, unknown>> | null }));
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: () => ({ data: boardDetail.data, isLoading: false, error: null }),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({
useWorkOrderUplifts: () => ({ data: woUplifts.data, isLoading: false, error: null }),
useWorkOrderUplifts: () => ({ data: woUplifts.data, isLoading: false, isError: false }),
}));
const canApproveState = vi.hoisted(() => ({ data: true as boolean | undefined }));
@ -28,7 +23,7 @@ const baseItem: UpliftQueueItem = {
requestedNTE: 250,
delta: 150,
vendorReason: "",
requestedAt: "2026-01-15T10:00:00Z",
requestedAt: "2026-01-15T15:00:00Z",
requestedByVendorName: "Gateway",
decidedAt: "",
decidedByName: "",
@ -50,6 +45,9 @@ const baseItem: UpliftQueueItem = {
woNumber: "WO-99",
site: "Site A",
serviceName: "Plumbing repair",
technicianName: "",
workOrderDispatcherName: "",
workOrderScheduledDate: "",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
@ -58,76 +56,150 @@ const baseItem: UpliftQueueItem = {
};
function renderModal(overrides: Partial<UpliftQueueItem> = {}, canRevoke = true) {
const onApprove = vi.fn();
const onReject = vi.fn();
const onRevoke = vi.fn();
const onOpenAttachment = vi.fn();
const callbacks = {
onClose: vi.fn(),
onApprove: vi.fn(),
onReject: vi.fn(),
onRevoke: vi.fn(),
onOpenAttachment: vi.fn(),
};
renderWithProviders(
<UpliftDetailModal
item={{ ...baseItem, ...overrides }}
canRevoke={canRevoke}
approvePending={false}
revokePending={false}
onClose={vi.fn()}
onApprove={onApprove}
onReject={onReject}
onRevoke={onRevoke}
onOpenAttachment={onOpenAttachment}
{...callbacks}
/>,
);
return { onApprove, onReject, onRevoke, onOpenAttachment };
return callbacks;
}
function section(name: string): HTMLElement {
return screen.getByRole("region", { name });
}
function fieldValue(container: HTMLElement, label: string): string {
const labelNode = within(container).getByText(label);
return labelNode.parentElement?.lastElementChild?.textContent ?? "";
}
describe("UpliftDetailModal", () => {
beforeEach(() => {
canApproveState.data = true;
boardDetail.data = {
info: { woNumber: "WO-99", site: "Site A", status: "Open", dueDate: "" },
};
woUplifts.data = [
{ id: 1, status: "auto_approved", amount: 100 },
{ id: 2, status: "approved", amount: 250 },
];
});
it("shows work order and request placeholders for a pending uplift", () => {
it("organizes content into Work order and Uplift request divided by a single rule", () => {
renderModal();
expect(screen.getByText("Unscheduled")).toBeInTheDocument();
expect(screen.getByText("No justification provided.")).toBeInTheDocument();
expect(screen.getByText("No attachments")).toBeInTheDocument();
const dialog = screen.getByRole("dialog");
const regions = within(dialog).getAllByRole("region");
expect(regions.map((region) => region.getAttribute("aria-label"))).toEqual([
"Work order",
"Uplift request",
]);
expect(within(dialog).getAllByRole("separator")).toHaveLength(1);
expect(within(dialog).queryByText("Request")).not.toBeInTheDocument();
});
it("opens an evidence attachment through the provided callback", () => {
it("renders the work order fields from the queue item", () => {
renderModal({
technicianName: "Tom Tech",
workOrderDispatcherName: "Dana Ruiz",
workOrderScheduledDate: "2026-04-10T00:00:00",
});
const workOrder = section("Work order");
expect(fieldValue(workOrder, "Service")).toBe("Plumbing repair");
expect(fieldValue(workOrder, "Vendor / Technician")).toBe("Gateway Plumbing · Tom Tech");
expect(fieldValue(workOrder, "Assigned Dispatcher")).toBe("Dana Ruiz");
expect(fieldValue(workOrder, "Scheduled")).toBe("Apr 10, 2026");
});
it("shows explicit placeholders for an empty work order, never a blank field", () => {
renderModal({ serviceName: "", vendorCompanyName: "", technicianName: "" });
const workOrder = section("Work order");
expect(fieldValue(workOrder, "Service")).toBe("Not specified");
expect(fieldValue(workOrder, "Vendor / Technician")).toBe("Not assigned");
expect(fieldValue(workOrder, "Assigned Dispatcher")).toBe("Unassigned");
expect(fieldValue(workOrder, "Scheduled")).toBe("Unscheduled");
});
it("renders requester, request date and time, and justification in a bordered block", () => {
renderModal({ vendorReason: "Scope grew after inspection" });
const request = section("Uplift request");
expect(fieldValue(request, "Requested By")).toBe("Gateway");
// The instant renders in the viewer's zone, so the calendar day is derived, not fixed.
const localDay = new Date(baseItem.requestedAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
expect(fieldValue(request, "Requested At")).toMatch(
new RegExp(`^${localDay} · \\d{1,2}:\\d{2} [AP]M$`, "u"),
);
expect(screen.getByTestId("uplift-justification")).toHaveTextContent(
"Scope grew after inspection",
);
});
it("shows explicit request placeholders when requester, date, notes and files are missing", () => {
renderModal({ requestedByVendorName: "", requestedAt: "" });
const request = section("Uplift request");
expect(fieldValue(request, "Requested By")).toBe("Unknown requester");
expect(fieldValue(request, "Requested At")).toBe("Not recorded");
const placeholder = within(screen.getByTestId("uplift-justification")).getByText(
"No justification provided.",
);
expect(placeholder).toHaveClass("italic");
expect(within(request).getByText("No attachments")).toBeInTheDocument();
});
it("renders attachments as a horizontal scrolling chip row that opens the file", () => {
const { onOpenAttachment } = renderModal({
evidenceDocumentId: "document-1",
evidenceFileName: "quote.pdf",
attachmentCount: 1,
attachmentCount: 3,
});
fireEvent.click(screen.getByText("quote.pdf"));
const row = screen.getByTestId("uplift-attachments");
expect(row).toHaveStyle({ overflowX: "auto", flexWrap: "nowrap" });
expect(within(row).getByText("+2")).toBeInTheDocument();
fireEvent.click(within(row).getByText("quote.pdf"));
expect(onOpenAttachment).toHaveBeenCalledWith(
expect.objectContaining({ evidenceDocumentId: "document-1" }),
);
});
it("falls back to the work order uplifts for the approved-on-WO breakdown", () => {
it("shows the approved-on-WO exposure breakdown inside the uplift request", () => {
renderModal();
expect(screen.getByText("Auto-approved")).toBeInTheDocument();
expect(screen.getByText("Admin-approved")).toBeInTheDocument();
expect(screen.getByText("$100.00")).toBeInTheDocument();
expect(screen.getByText("$250.00")).toBeInTheDocument();
expect(screen.getByText("$350.00")).toBeInTheDocument();
const request = section("Uplift request");
expect(within(request).getByText("Approved on WO")).toBeInTheDocument();
expect(within(request).getByText("Auto-approved (within allowance)")).toBeInTheDocument();
expect(within(request).getByText("Admin-approved")).toBeInTheDocument();
expect(within(request).getByText("$100.00")).toBeInTheDocument();
expect(within(request).getByText("$350.00")).toBeInTheDocument();
});
it("offers status-specific actions for a pending uplift", () => {
it("offers Reject then Approve for a pending uplift", () => {
const { onApprove, onReject } = renderModal();
const footerButtons = screen
.getAllByRole("button")
.map((button) => button.textContent)
.filter((label) => label === "Reject" || label === "Approve" || label === "Revoke");
expect(footerButtons).toEqual(["Reject", "Approve"]);
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
expect(onApprove).toHaveBeenCalled();
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
expect(onReject).toHaveBeenCalled();
});
@ -148,12 +220,17 @@ describe("UpliftDetailModal", () => {
expect(onReject).not.toHaveBeenCalled();
});
it("offers Revoke for an approved uplift and disables it on a closed work order", () => {
it("offers Revoke for an approved uplift and disables it with a tooltip on a closed work order", async () => {
const { onRevoke } = renderModal({ status: "Approved", workOrderClosed: true });
expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument();
const revoke = screen.getByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();
fireEvent.mouseOver(revoke);
expect(
await screen.findByText("This work order is closed. Uplifts can no longer be revoked."),
).toBeInTheDocument();
fireEvent.click(revoke);
expect(onRevoke).not.toHaveBeenCalled();
});
@ -169,6 +246,26 @@ describe("UpliftDetailModal", () => {
expect(onRevoke).not.toHaveBeenCalled();
});
it.each(["Rejected", "Withdrawn"])("is a read-only record with no actions when %s", (status) => {
renderModal({ status });
for (const name of ["Approve", "Reject", "Revoke"]) {
expect(screen.queryByRole("button", { name })).not.toBeInTheDocument();
}
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
});
it("closes from the header X without triggering any decision", () => {
const callbacks = renderModal();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
expect(callbacks.onApprove).not.toHaveBeenCalled();
expect(callbacks.onReject).not.toHaveBeenCalled();
expect(callbacks.onRevoke).not.toHaveBeenCalled();
});
it("does not render a heading nested inside the dialog title heading", () => {
renderModal();

View file

@ -42,6 +42,9 @@ const pendingItem: UpliftQueueItem = {
woNumber: "WO-99",
site: "Site A",
serviceName: "Plumbing repair",
technicianName: "",
workOrderDispatcherName: "",
workOrderScheduledDate: "",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,

View file

@ -2,6 +2,7 @@ import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { useSlideOverEditState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-edit-state";
import type { SlideOverTab } from "@/app/(protected)/workorders/_components/detail/use-work-order-slide-over";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
vendorAssignedMessage,
@ -111,6 +112,34 @@ describe("useSlideOverEditState completed lock", () => {
expect(onSave).not.toHaveBeenCalled();
});
it("applies a repeated same-row tab request without resetting the draft", () => {
const open = baseRow();
const setTab = vi.fn();
const { result, rerender } = renderHook(
({ initialTab, openRequestKey }) =>
useSlideOverEditState({
row: open,
editMode: false,
infoSource: open,
onSave: vi.fn(),
onClose: vi.fn(),
closeDisabled: false,
setTab,
initialTab,
openRequestKey,
}),
{ initialProps: { initialTab: "info" as SlideOverTab, openRequestKey: 1 } },
);
act(() => {
result.current.handleDraftChange({ woNumber: "edited" });
});
rerender({ initialTab: "extras", openRequestKey: 2 });
expect(setTab).toHaveBeenLastCalledWith("extras");
expect(result.current.activeDraft?.woNumber).toBe("edited");
});
it("toasts vendor assigned after a successful save that includes vendorId", () => {
const onSave = vi.fn();
const open = baseRow();

View file

@ -0,0 +1,137 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters";
import { useWorkOrderDeepLink } from "@/app/(protected)/workorders/_hooks/use-work-order-deep-link";
const detailState = vi.hoisted(() => ({
data: undefined as unknown,
error: null as Error | null,
requestedIds: [] as (string | number | undefined)[],
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: (id: string | number | undefined) => {
detailState.requestedIds.push(id);
return { data: id == null ? undefined : detailState.data, error: detailState.error };
},
}));
function wrapper(route: string) {
return function Wrapper({ children }: { children: ReactNode }) {
return <MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>;
};
}
describe("work order deep link", () => {
beforeEach(() => {
detailState.data = { info: { id: 42, woNumber: "1042" } };
detailState.error = null;
detailState.requestedIds = [];
});
it("opens the linked work order on the requested tab and clears the link", async () => {
const onOpen = vi.fn();
const { result } = renderHook(
() => {
useWorkOrderDeepLink(onOpen);
return useLocation();
},
{ wrapper: wrapper("/workorders?wo=42&tab=extras") },
);
await waitFor(() => expect(result.current.search).toBe(""));
expect(detailState.requestedIds).toContain("42");
expect(onOpen).toHaveBeenCalledTimes(1);
expect(onOpen).toHaveBeenCalledWith({ id: 42, woNumber: "1042" }, false, "extras");
});
it("falls back to the info tab for an unknown tab", async () => {
const onOpen = vi.fn();
renderHook(() => useWorkOrderDeepLink(onOpen), {
wrapper: wrapper("/workorders?wo=42&tab=nope"),
});
await waitFor(() => expect(onOpen).toHaveBeenCalledWith(expect.anything(), false, "info"));
});
it("clears the link without opening anything when the work order cannot load", async () => {
detailState.data = undefined;
detailState.error = new Error("not found");
const onOpen = vi.fn();
const { result } = renderHook(
() => {
useWorkOrderDeepLink(onOpen);
return useLocation();
},
{ wrapper: wrapper("/workorders?wo=42") },
);
await waitFor(() => expect(result.current.search).toBe(""));
expect(onOpen).not.toHaveBeenCalled();
});
it("ignores a board URL without a work order link", () => {
const onOpen = vi.fn();
renderHook(() => useWorkOrderDeepLink(onOpen), { wrapper: wrapper("/workorders?tab=extras") });
expect(detailState.requestedIds.every((id) => id == null)).toBe(true);
expect(onOpen).not.toHaveBeenCalled();
});
});
describe("board drilldown links", () => {
it("applies a second drilldown link while the board stays mounted", async () => {
const onApply = vi.fn();
const { result } = renderHook(
() => {
useDashboardDrilldownFilters(onApply);
return { navigate: useNavigate(), location: useLocation() };
},
{ wrapper: wrapper("/workorders?dateFrom=2026-09-01&dateTo=2026-09-30") },
);
await waitFor(() => expect(onApply).toHaveBeenCalledTimes(1));
await waitFor(() => expect(result.current.location.search).toBe(""));
act(() => {
void result.current.navigate(
"/workorders?dateFrom=1970-01-01&dateTo=2099-12-31&dispatchers=__unassigned",
);
});
await waitFor(() => expect(onApply).toHaveBeenCalledTimes(2));
expect(onApply.mock.calls[1]?.[0]).toMatchObject({
customFrom: "1970-01-01",
customTo: "2099-12-31",
});
});
it("keeps dashboard provenance separate from notification drilldowns", async () => {
const onApply = vi.fn();
const { result } = renderHook(
() => {
const fromDashboard = useDashboardDrilldownFilters(onApply);
return { fromDashboard, navigate: useNavigate(), location: useLocation() };
},
{
wrapper: wrapper("/workorders?dateFrom=2026-09-01&dateTo=2026-09-30&fromDashboard=1"),
},
);
await waitFor(() => expect(onApply).toHaveBeenCalledTimes(1));
await waitFor(() => expect(result.current.location.search).toBe(""));
expect(result.current.fromDashboard).toBe(true);
act(() => {
void result.current.navigate(
"/workorders?dateFrom=1970-01-01&dateTo=2099-12-31&dispatchers=__unassigned",
);
});
await waitFor(() => expect(onApply).toHaveBeenCalledTimes(2));
await waitFor(() => expect(result.current.fromDashboard).toBe(false));
});
});

View file

@ -150,7 +150,7 @@ function DayAssignHarness({
dispatcherId: id,
dispatcherName: name,
});
const patched = await patchBoardField(item.id, ops[0]!);
const patched = await patchBoardField(item.id, ops[0] as { field: string });
setRow({ ...patched, dispatcherId: id, dispatcherName: name });
}}
/>

View file

@ -0,0 +1,362 @@
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useLocation } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import NotificationsPage from "@/app/(protected)/notifications";
import { NotificationBell } from "@/components/notifications/notification-bell";
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
import { renderWithProviders } from "@/test/test-utils";
const apiGet = vi.hoisted(() => vi.fn());
vi.mock("@/api/api", () => ({ apiGet }));
type ItemInput = {
id: string;
reason: string;
severity: "Critical" | "High" | "Medium" | "Low";
title: string;
count?: number;
rowType?: "dismissable" | "acknowledge";
target: { kind: "workOrder" | "queue"; workOrderId?: number; tab?: string; queue?: string };
workOrders?: { id: number; number: string }[];
};
function item(input: ItemInput) {
return {
rowType: "dismissable" as ItemInput["rowType"],
count: 1,
triggeredAt: "2026-09-18T12:00:00Z",
workOrders: [] as { id: number; number: string }[],
...input,
};
}
function section(reason: string, label: string, items: ReturnType<typeof item>[]) {
const severity = items[0]?.severity ?? "Low";
return {
reason,
label,
severity,
count: items.reduce((sum, row) => sum + row.count, 0),
items,
};
}
const unassigned = item({
id: "unassigned",
reason: "unassigned",
severity: "High",
title: "3 work orders are unassigned",
count: 3,
target: { kind: "queue", queue: "unassigned" },
});
const avetaFirst = item({
id: "aveta-missing-41",
reason: "aveta-missing",
severity: "Medium",
title: "WO #1041 is missing the Aveta document",
target: { kind: "workOrder", workOrderId: 41, tab: "extras" },
});
const avetaSecond = item({
id: "aveta-missing-42",
reason: "aveta-missing",
severity: "Medium",
title: "WO #1042 is missing the Aveta document",
target: { kind: "workOrder", workOrderId: 42, tab: "extras" },
});
const noVendor = item({
id: "no-vendor-7",
reason: "no-vendor",
severity: "Medium",
title: "WO #1007 starts within 48h with no vendor assigned",
target: { kind: "workOrder", workOrderId: 7, tab: "info" },
});
const acknowledgeRow = item({
id: "sla-9",
reason: "sla",
severity: "Critical",
rowType: "acknowledge",
title: "WO #1009 missed its response deadline",
target: { kind: "workOrder", workOrderId: 9, tab: "info" },
});
function feed(...sections: ReturnType<typeof section>[]) {
return { generatedAt: "2026-09-18T12:00:00Z", sections };
}
const standardFeed = feed(
section("unassigned", "Unassigned", [unassigned]),
section("no-vendor", "No Vendor", [noVendor]),
section("aveta-missing", "Aveta Missing", [avetaFirst, avetaSecond]),
);
function LocationProbe() {
const location = useLocation();
return <output aria-label="Current location">{`${location.pathname}${location.search}`}</output>;
}
function renderBell() {
return renderWithProviders(
<NotificationSessionProvider>
<NotificationBell />
<LocationProbe />
</NotificationSessionProvider>,
{ withAuth: false },
);
}
async function openPanel(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole("button", { name: /^Notifications, \d+ unread$/ }));
return screen.findByRole("dialog");
}
function sectionLabels(panel: HTMLElement) {
return within(panel)
.getAllByRole("region")
.map((region) => region.getAttribute("aria-label"));
}
describe("notification center", () => {
beforeEach(() => {
apiGet.mockReset();
apiGet.mockResolvedValue(standardFeed);
});
it("requests the signed-in user's feed and counts every unread item on the bell", async () => {
renderBell();
expect(
await screen.findByRole("button", { name: "Notifications, 4 unread" }),
).toBeInTheDocument();
expect(apiGet).toHaveBeenCalledWith("notifications");
});
it("shows labeled sections with live counts in severity then reason order", async () => {
const user = userEvent.setup();
renderBell();
const panel = await openPanel(user);
expect(sectionLabels(panel)).toEqual(["Unassigned, 3", "No Vendor, 1", "Aveta Missing, 2"]);
const aveta = within(panel).getByRole("region", { name: "Aveta Missing, 2" });
expect(within(aveta).getByRole("heading", { name: "Aveta Missing" })).toBeInTheDocument();
expect(within(aveta).getAllByRole("listitem")).toHaveLength(2);
});
it("never renders an empty section and shows the empty state when nothing is left", async () => {
apiGet.mockResolvedValue(
feed(section("unassigned", "Unassigned", [unassigned]), section("due-soon", "Due Soon", [])),
);
const user = userEvent.setup();
renderBell();
const panel = await openPanel(user);
expect(sectionLabels(panel)).toEqual(["Unassigned, 3"]);
await user.click(within(panel).getByRole("button", { name: "Dismiss notification" }));
expect(within(panel).queryAllByRole("region")).toHaveLength(0);
expect(within(panel).getByText("Nothing needs your attention")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Notifications", hidden: true })).toBeInTheDocument();
});
it("styles unread rows apart from read ones and marks everything read on request", async () => {
const user = userEvent.setup();
renderBell();
const panel = await openPanel(user);
const rows = within(panel).getAllByRole("listitem");
expect(rows.every((row) => row.dataset.unread === "true")).toBe(true);
expect(within(panel).getAllByText("Unread:", { exact: false })).toHaveLength(4);
await user.click(within(panel).getByRole("button", { name: "Mark all read" }));
expect(
within(panel)
.getAllByRole("listitem")
.every((row) => row.dataset.unread === "false"),
).toBe(true);
expect(within(panel).queryByText("Unread:", { exact: false })).not.toBeInTheDocument();
expect(within(panel).getByRole("button", { name: "Mark all read" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Notifications", hidden: true })).toBeInTheDocument();
});
it("dismisses one item, lowering its section count, and keeps the dismissal to this session only", async () => {
const user = userEvent.setup();
const { unmount } = renderBell();
const panel = await openPanel(user);
const aveta = within(panel).getByRole("region", { name: "Aveta Missing, 2" });
const firstRow = within(aveta).getByText(avetaFirst.title).closest("li");
expect(firstRow).not.toBeNull();
await user.click(
within(firstRow as HTMLElement).getByRole("button", { name: "Dismiss notification" }),
);
expect(within(panel).getByRole("region", { name: "Aveta Missing, 1" })).toBeInTheDocument();
expect(within(panel).queryByText(avetaFirst.title)).not.toBeInTheDocument();
expect(within(panel).getByText(avetaSecond.title)).toBeInTheDocument();
unmount();
renderBell();
const freshPanel = await openPanel(user);
expect(within(freshPanel).getByText(avetaFirst.title)).toBeInTheDocument();
});
it("re-sorts sections after a dismissal changes which severity leads", async () => {
apiGet.mockResolvedValue(
feed(
section("aveta-missing", "Aveta Missing", [
item({ ...avetaFirst, severity: "High", title: "WO #1041 urgent" }),
avetaSecond,
]),
section("no-vendor", "No Vendor", [noVendor]),
),
);
const user = userEvent.setup();
renderBell();
const panel = await openPanel(user);
expect(sectionLabels(panel)).toEqual(["Aveta Missing, 2", "No Vendor, 1"]);
const highRow = within(panel).getByText("WO #1041 urgent").closest("li") as HTMLElement;
await user.click(within(highRow).getByRole("button", { name: "Dismiss notification" }));
expect(sectionLabels(panel)).toEqual(["No Vendor, 1", "Aveta Missing, 1"]);
});
it("clears every dismissable item but keeps rows that need acknowledging", async () => {
apiGet.mockResolvedValue(
feed(
section("sla", "SLA at Risk", [acknowledgeRow]),
section("unassigned", "Unassigned", [unassigned]),
),
);
const user = userEvent.setup();
renderBell();
const panel = await openPanel(user);
const slaRow = within(panel).getByText(acknowledgeRow.title).closest("li") as HTMLElement;
expect(within(slaRow).queryByRole("button", { name: "Dismiss notification" })).toBeNull();
await user.click(within(panel).getByRole("button", { name: "Clear all" }));
expect(sectionLabels(panel)).toEqual(["SLA at Risk, 1"]);
expect(within(panel).getByText(acknowledgeRow.title)).toBeInTheDocument();
});
it("opens the unassigned queue with the existing board filters and closes the panel", async () => {
const user = userEvent.setup();
renderBell();
const panel = await openPanel(user);
await user.click(within(panel).getByRole("button", { name: /3 work orders are unassigned/ }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
const location = screen.getByRole("status", { name: "Current location" }).textContent ?? "";
const url = new URL(location, "http://localhost");
expect(url.pathname).toBe("/workorders");
expect(url.searchParams.get("dispatchers")).toBe("__unassigned");
expect(url.searchParams.get("dateFrom")).toBe("1970-01-01");
expect(url.searchParams.get("dateTo")).toBe("2099-12-31");
expect(url.searchParams.get("statuses")).toBeTruthy();
expect(url.searchParams.get("fromDashboard")).toBeNull();
expect(screen.getByRole("button", { name: "Notifications, 3 unread" })).toBeInTheDocument();
});
it("opens an Aveta item on that work order's upload tab", async () => {
const user = userEvent.setup();
renderBell();
const panel = await openPanel(user);
await user.click(within(panel).getByRole("button", { name: new RegExp(avetaSecond.title) }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent(
"/workorders?wo=42&tab=extras",
);
});
it("serves the same feed as a full page and sends No Vendor straight to choosing a vendor", async () => {
const user = userEvent.setup();
renderWithProviders(
<NotificationSessionProvider>
<NotificationsPage />
<LocationProbe />
</NotificationSessionProvider>,
{ withAuth: false, route: "/notifications" },
);
expect(await screen.findByRole("region", { name: "No Vendor, 1" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Notifications" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: new RegExp(noVendor.title) }));
expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent(
"/workorders/edit/7?assignVendor=1",
);
});
it("links every work order in a vendor conflict", async () => {
apiGet.mockResolvedValue(
feed(
section("vendor-conflict", "Vendor Conflict", [
item({
id: "vendor-conflict-501-502",
reason: "vendor-conflict",
severity: "Medium",
title: "Acme Plumbing has overlapping appointments",
count: 2,
target: { kind: "workOrder", workOrderId: 501, tab: "info" },
workOrders: [
{ id: 501, number: "501" },
{ id: 502, number: "502" },
],
}),
]),
),
);
const user = userEvent.setup();
renderWithProviders(
<NotificationSessionProvider>
<NotificationsPage />
<LocationProbe />
</NotificationSessionProvider>,
{ withAuth: false, route: "/notifications" },
);
const conflicts = await screen.findByRole("region", { name: "Vendor Conflict, 2" });
expect(within(conflicts).getByRole("link", { name: "WO #501" })).toHaveAttribute(
"href",
"/workorders/501",
);
await user.click(within(conflicts).getByRole("link", { name: "WO #502" }));
expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent(
"/workorders/502",
);
const row = within(conflicts).getByRole("listitem");
expect(row.dataset.unread).toBe("false");
});
it("tells the user when the feed cannot load", async () => {
apiGet.mockRejectedValue(new Error("boom"));
renderWithProviders(
<NotificationSessionProvider>
<NotificationsPage />
</NotificationSessionProvider>,
{ withAuth: false, route: "/notifications" },
);
expect(
await screen.findByText("Notifications could not be loaded. They will retry automatically."),
).toBeInTheDocument();
});
});

View file

@ -51,9 +51,12 @@ describe("work-order drilldown links", () => {
});
it("renders plain or query-bearing work order URLs", () => {
expect(workOrderDrilldownUrl(new URLSearchParams())).toBe("/workorders");
expect(workOrderDrilldownUrl(new URLSearchParams())).toBe("/workorders?fromDashboard=1");
expect(workOrderDrilldownUrl(new URLSearchParams({ statuses: "Scheduled" }))).toBe(
"/workorders?statuses=Scheduled",
"/workorders?statuses=Scheduled&fromDashboard=1",
);
expect(
workOrderDrilldownUrl(new URLSearchParams({ statuses: "Scheduled" }), "notification"),
).toBe("/workorders?statuses=Scheduled");
});
});

View file

@ -42,4 +42,53 @@ describe("upliftsApi", () => {
await expect(upliftsApi.canApprove(2)).resolves.toBe(false);
});
describe("openEvidence", () => {
function evidenceResponse(body: string, contentType: string): Response {
return new Response(body, {
headers: {
"Content-Type": contentType,
"Content-Disposition": 'attachment; filename="quote.pdf"',
},
});
}
function fakeTab() {
return { location: { href: "about:blank" }, close: vi.fn() };
}
beforeEach(() => {
URL.createObjectURL = vi.fn(() => "blob:evidence");
URL.revokeObjectURL = vi.fn();
});
it("renders an inert evidence type in the tab opened by the click", async () => {
apiRequestRaw.mockResolvedValueOnce(evidenceResponse("%PDF", "application/pdf"));
const tab = fakeTab();
await upliftsApi.openEvidence(7, tab as unknown as Window);
expect(apiRequestRaw).toHaveBeenCalledWith(
"get",
"uplifts/7/evidence",
undefined,
"upliftsApi.openEvidence",
);
expect(tab.location.href).toBe("blob:evidence");
expect(tab.close).not.toHaveBeenCalled();
});
it("downloads a type the browser could execute instead of rendering it on the app origin", async () => {
apiRequestRaw.mockResolvedValueOnce(evidenceResponse("<svg/>", "image/svg+xml"));
const tab = fakeTab();
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
await upliftsApi.openEvidence(8, tab as unknown as Window);
expect(tab.location.href).toBe("about:blank");
expect(tab.close).toHaveBeenCalled();
expect(click).toHaveBeenCalled();
click.mockRestore();
});
});
});

View file

@ -108,9 +108,25 @@ describe("mapUpliftQueueItem", () => {
expect(result.workOrderClosed).toBe(false);
});
it("maps the detail modal work-order context from the queue contract", () => {
const result = mapUpliftQueueItem({
id: 4,
TechnicianName: "Tom Tech",
WorkOrderDispatcherName: "Dana Ruiz",
WorkOrderScheduledDate: "2026-04-10T09:30:00",
});
expect(result.technicianName).toBe("Tom Tech");
expect(result.workOrderDispatcherName).toBe("Dana Ruiz");
expect(result.workOrderScheduledDate).toBe("2026-04-10T09:30:00");
});
it("tolerates absent optional queue fields", () => {
const result = mapUpliftQueueItem({ id: 3 });
expect(result.technicianName).toBe("");
expect(result.workOrderDispatcherName).toBe("");
expect(result.workOrderScheduledDate).toBe("");
expect(result.woNumber).toBe("");
expect(result.site).toBe("");
expect(result.serviceName).toBe("");

View file

@ -658,6 +658,100 @@ describe("workOrdersApi.patchBoardField", () => {
});
});
describe("workOrdersApi.updatePoc", () => {
beforeEach(() => {
apiPatchFn.mockReset();
});
it("PATCHes poc payload to workorders/{id}/poc and maps row", async () => {
apiPatchFn.mockResolvedValue({
ok: true,
json: async () => ({
id: 10,
siteCode: "BK5",
pocName: "Manual POC",
pocPhone: "312-555-0001",
pocNotes: "Call first",
lifecycleStatusLabel: "Scheduled",
rowVersion: "AAAAAAAB",
dispatchRowVersion: null,
primaryDispatchId: null,
vendorId: null,
}),
});
const row = await workOrdersApi.updatePoc(10, {
pocName: "Manual POC",
pocPhone: "312-555-0001",
pocNotes: "Call first",
workOrderVersion: "AAAAAAA=",
});
expect(apiPatchFn).toHaveBeenCalledWith(API_PATHS.workOrder.pocPatch(10), {
json: {
pocName: "Manual POC",
pocPhone: "312-555-0001",
pocNotes: "Call first",
workOrderVersion: "AAAAAAA=",
},
throwHttpErrors: false,
});
expect(row.pocName).toBe("Manual POC");
expect(row.rowVersion).toBe("AAAAAAAB");
});
it("throws BoardPatchConflictError with currentState on 409", async () => {
apiPatchFn.mockResolvedValue({
ok: false,
status: 409,
json: async () => ({
message: "The record was modified by another user. Refresh and retry.",
currentState: {
id: 10,
siteCode: "BK5",
rowVersion: "NEWVER==",
lifecycleStatusLabel: "Scheduled",
},
}),
});
const { BoardPatchConflictError } =
await import("@/domain/work-orders/errors/board-patch-errors");
await expect(
workOrdersApi.updatePoc(10, {
pocName: "Manual POC",
pocPhone: "312-555-0001",
pocNotes: "",
workOrderVersion: "STALE==",
}),
).rejects.toBeInstanceOf(BoardPatchConflictError);
});
it("throws BoardPatchValidationError on 422 ReadOnly", async () => {
apiPatchFn.mockResolvedValue({
ok: false,
status: 422,
json: async () => ({
code: "ReadOnly",
message: "Work order is read-only in its current status.",
}),
});
await expect(
workOrdersApi.updatePoc(10, {
pocName: "Manual POC",
pocPhone: "312-555-0001",
pocNotes: "",
workOrderVersion: "AAAAAAA=",
}),
).rejects.toMatchObject({
name: "BoardPatchValidationError",
code: "ReadOnly",
});
});
});
describe("workOrdersApi.createComment", () => {
beforeEach(() => {
apiPost.mockReset();

View file

@ -34,6 +34,12 @@ describe("isLocalOnlyTablePatch", () => {
it("treats scheduleEnd and schedMode as local-only overlays", () => {
expect(isLocalOnlyTablePatch({ scheduleEnd: "2026-06-12", schedMode: "range" })).toBe(true);
});
it("does not treat POC edits as local-only", () => {
expect(isLocalOnlyTablePatch({ pocName: "Pat", pocPhone: "111", pocNotes: "call" })).toBe(
false,
);
});
});
describe("expandTablePatchToBoardFieldOps", () => {
@ -185,7 +191,33 @@ describe("expandTablePatchToBoardFieldOps", () => {
pocPhone: "111",
pocNotes: "call",
}),
).toEqual([{ field: "siteCode", value: "BK5" }]);
).toEqual([
{
kind: "poc",
pocName: "Pat",
pocPhone: "111",
pocNotes: "call",
},
{ field: "siteCode", value: "BK5" },
]);
});
it("emits one composite POC op before scalar ops", () => {
expect(
expandTablePatchToBoardFieldOps({
pocName: "Pat",
pocNotes: "call first",
docStatus: "Yes",
}),
).toEqual([
{
kind: "poc",
pocName: "Pat",
pocPhone: undefined,
pocNotes: "call first",
},
{ field: "docStatus", value: "Yes" },
]);
});
it("keeps dueDate and scheduledDate patches independent", () => {

View file

@ -9,12 +9,14 @@ import type {
const getById = vi.fn();
const update = vi.fn();
const updatePoc = vi.fn();
const patchBoardField = vi.fn();
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
workOrdersApi: {
getById: (...args: unknown[]) => getById(...args),
update: (...args: unknown[]) => update(...args),
updatePoc: (...args: unknown[]) => updatePoc(...args),
patchBoardField: (...args: unknown[]) => patchBoardField(...args),
},
}));
@ -121,6 +123,7 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
beforeEach(() => {
getById.mockReset();
update.mockReset();
updatePoc.mockReset();
patchBoardField.mockReset();
serverState = { ...BASE_SERVER };
rowState = { ...BASE_ROW };
@ -829,6 +832,40 @@ describe("useWorkOrderTableMutations type and severity", () => {
});
});
describe("useWorkOrderTableMutations POC persistence", () => {
it("fills omitted POC fields from the current row before the full replacement", async () => {
const currentRow = {
...BASE_ROW,
pocName: "Alex",
pocPhone: "555-0000",
pocNotes: "Gate",
};
updatePoc.mockResolvedValue({ ...currentRow, pocName: "Jordan", rowVersion: "wo-v2" });
const { result } = renderHook(
() =>
useWorkOrderTableMutations({
onPatch: vi.fn(),
clearPatch: () => undefined,
getRow: () => currentRow,
}),
{ wrapper: makeWrapper() },
);
act(() => {
result.current.patchField(42, { pocName: "Jordan" });
});
await waitFor(() => expect(updatePoc).toHaveBeenCalled());
expect(updatePoc).toHaveBeenCalledWith(42, {
pocName: "Jordan",
pocPhone: "555-0000",
pocNotes: "Gate",
workOrderVersion: "AAAAAAA=",
});
});
});
describe("useWorkOrderTableMutations audit cache refresh", () => {
it("invalidates work-order queries after a successful status transition", async () => {
const client = new QueryClient({

View file

@ -67,7 +67,9 @@ describe("slide-over-draft", () => {
expect(SLIDE_OVER_EDIT_KEYS).toContain("vendorId");
expect(SLIDE_OVER_EDIT_KEYS).toContain("additionalContacts");
expect(SLIDE_OVER_EDIT_KEYS).toContain("avetaRequired");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("pocName");
expect(SLIDE_OVER_EDIT_KEYS).toContain("pocName");
expect(SLIDE_OVER_EDIT_KEYS).toContain("pocPhone");
expect(SLIDE_OVER_EDIT_KEYS).toContain("pocNotes");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("techPhone");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("company");
@ -85,11 +87,11 @@ describe("slide-over-draft", () => {
expect(buildSlideOverPatch(vendorDraft, row)).toEqual({ vendorId: "v2" });
});
it("ignores POC-only edits for dirty state", () => {
it("tracks POC edits for dirty state and patch", () => {
const row = baseRow();
const draft = baseRow({ pocName: "Jordan", pocPhone: "555-0100" });
expect(isSlideOverDraftDirty(draft, row)).toBe(false);
expect(buildSlideOverPatch(draft, row)).toEqual({});
expect(isSlideOverDraftDirty(draft, row)).toBe(true);
expect(buildSlideOverPatch(draft, row)).toEqual({ pocName: "Jordan", pocPhone: "555-0100" });
});
it("detects dirty draft when a tracked field changes", () => {

View file

@ -1,43 +0,0 @@
## Jira
- [SH-192](https://luby-us.atlassian.net/browse/SH-192) — severity on Emergency/Reactive
- Related: [SH-107](https://luby-us.atlassian.net/browse/SH-107) (WO# provisional SH…), [SH-108](https://luby-us.atlassian.net/browse/SH-108) (POC)
## QA residual
- P0 wizard fixed: Type optional (default PM); WO# alphanumeric / SH provisional (not digits-only).
- P1: COL_HELP tooltips, Scheduled-requires-date — follow-up.
## Summary
- Add SEV 1–5 domain field on board row/create/patch and Type cell modal for Emergency/Reactive.
- Wizard gates: required WO# (alphanumeric/SH ok), Type optional (defaults PM), severity when E/R, POC name/phone on step 2 with error UX.
## Scope
### In
- `severity` on WorkOrderBoardRowApi / create payload / editable field / table row / wizard draft
- Type cell popover + TypeSeverityModal + SeverityBadge
- Wizard step1/step2 validation + footer attempt-continue errors
- Unit tests for canContinue* and severity patch mapping
### Out (follow-up PRs)
- Advanced filter severity facet (PR5)
- Uplift UI, Completed Date column, multi-POC, SH auto-numbering beyond wizard gate
## API / BE
- Contract assumed: `severity` on GET board/search row, POST create, and PATCH field `severity`
- Blocked on BE: none for FE wiring; 422 if severity missing for E/R should be handled by BE
## Test plan
- [ ] Board: pick Emergency/Reactive → severity modal required; badge shows SEV
- [ ] Wizard: cannot continue step1 without WO#; E/R without severity; step2 without POC
- [ ] `npm run verify` green locally
## Base
- Base branch: `dev`

View file

@ -1,39 +0,0 @@
# PR4 — Pending uplift close gate (+ SH-114 closability union)
## Summary
Blocks marking a work order **Completed** (and future completion-document generation) when closability gaps remain — **SH-114 field gates ∪ pending uplift** (SH-188).
- Unifies `wo-closability.ts` / `getClosabilityGaps` with hierarchy: **fields → uplift pending → completion document / photos**.
- Field gates: dispatcher, technician, completed date, real WO# (non-SH), service; then PM/Overdue completion doc; Emergency/Reactive ≥1 photo.
- Pending uplift via `hasPendingUplift` or `upliftSummary.pendingCount`.
- Completed tooltip uses design copy when the **only** gap is pending uplift.
- Maps optional `completedDate` on board rows for the Completed Date gate.
## Jira
- [SH-188](https://luby-us.atlassian.net/browse/SH-188) — pending uplift close gate
- Related: [SH-114](https://luby-us.atlassian.net/browse/SH-114) — closability field matrix (pulled into this branch so sister merges do not wipe gates)
## Comp Doc Generate
`CompDocDialog` is **not** mounted on `dev` yet. This PR exports `canGenerateCompDoc(row)` so the completion-doc slice can block generate/open with the same reason string. No UI wiring in this PR.
## Out of scope
- Full uplift CRUD (PR3)
- Advanced filter facets for uplift
- Scheduled-requires-date gate (design P1)
- COL_HELP tooltips (P1)
## Test plan
- [ ] Row with all fields ok + `hasPendingUplift: true` — Completed disabled; design tooltip about pending uplift.
- [ ] Row missing dispatcher / completed date — Completed disabled; `Missing: …` tooltip.
- [ ] Row fully closable — Completed selectable.
- [ ] `canGenerateCompDoc` blocked when gaps exist.
- [ ] Board API `completedDate` / `upliftSummary.pendingCount` map through to the gate.
## Base
Branch `feature/wo-uplift-pending-close-gate` from local `dev`. Merge **before** or instead of stacking raw SH-114 alone with an uplift-only closability overwrite.

View file

@ -1,27 +0,0 @@
## Jira
- [SH-193](https://luby-us.atlassian.net/browse/SH-193)
## QA residual
- P0 done: technician is staged as a **local draft** until the vendor dialog is saved / wizard submitted — Cancel does not orphan a vendor row.
## Summary
- Adds **+ Add technician** to the WO board vendor dialog when a company context is known (from the row or the current selection).
- Stages a draft technician locally (`draft:` id) without calling `POST vendors` until the dialog save commits the selection.
- On save, create runs via the existing **`POST vendors`** REST endpoint (`vendorsApi.create` / `useCreateVendor`) using the company profile + new `contactName`.
## Backend
Uses the established vendor create API (`API_PATHS.rest.vendors` → `POST /vendors`). Each vendor record represents one technician; linking to an existing company is done by sending the same `companyName` (and optional `companyId` when available). **No new endpoint**.
## Test plan
- [ ] Open vendor dialog → **+ Add technician** → fill name → Cancel dialog → no vendor create API call.
- [ ] Add technician → Save dialog → `POST /vendors` once; row shows new tech.
- [ ] `npm run verify`
## Out of scope
- Multi-POC, multi-tech arrays on WO, vendors directory redesign.

View file

@ -1,41 +0,0 @@
# PR9 — Work order comment @mentions
## Jira
- [SH-217](https://luby-us.atlassian.net/browse/SH-217) — FE @mention autocomplete / chips
- Related BE: [SH-75](https://luby-us.atlassian.net/browse/SH-75)
## QA residual
- Parcial / Blocked-BE for notification + structured `mentions[]` confirm; token chip polish = P1.
## Summary
- Adds `@` autocomplete to the internal comments composer on work order detail (`/workorders/:id`).
- Encodes selected users as `@[userId:Display Name]` tokens in comment text so mentions stay readable when the backend ignores the optional payload field.
- Sends `mentions: string[]` on `POST workorders/{id}/comments` when the composer contains encoded tokens.
- Highlights mentions in the comment thread using encoded tokens and/or `mentions` returned by the API.
## Out of scope
- Notification Center deep-linking for mention events.
- Backend notification triggers beyond the FE payload.
## BE contract
See `docs/work-orders/pending-be-contract.md` (Comments section):
| Field | Status |
| ---------------------------------------- | -------- |
| POST `{ text, mentions?: string[] }` | FE ready |
| Response includes mentions for highlight | FE ready |
Until BE confirms, encoded tokens in `text` preserve highlight behavior client-side.
## Test plan
- [ ] Open a work order detail → Internal tab → type `@` and pick a dispatcher from the list.
- [ ] Submit comment; verify POST body includes `text` with encoded token and `mentions` array.
- [ ] Reload detail; verify mention is highlighted in the thread (token and/or API `mentions`).
- [ ] Type `@` mid-word (e.g. email) — autocomplete should not open.
- [ ] Run `npm run verify`.