Compare commits

..

No commits in common. "3e5fba4d2b89c5bcf8b85a09fc06e13f08bc673b" and "73e3362fcf7fca89d8826e6cb233d45a79ad7ee7" have entirely different histories.

82 changed files with 943 additions and 2706 deletions

View file

@ -1,22 +0,0 @@
<!--
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,6 +49,3 @@ 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=main)](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=dev)](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,8 +13,7 @@ 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, deployed from `main`) and
<https://staging.seahaven.com> (staging, deployed from `vX.Y.Z-staging` tags)
- **Hosted at:** <https://dev.seahaven.com> (dev environment; the only environment today)
- **Backend API:** `https://api.dev.seahaven.com/api` (called directly, cross-origin) — source: `Sea-Haven-Industries/shoc-backend`
## Architecture
@ -123,19 +122,16 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
## Contributing
- Branch from `main` with a kebab-case description and a prefix matching the
work: `feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, or `refactor/`
- Branch from `dev` with a kebab-case description and a prefix matching the
work: `feature/`, `bug/`, `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`. 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.
- 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.
- 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,19 +90,3 @@ 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: 141 KiB

After

Width:  |  Height:  |  Size: 140 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: 143 KiB

After

Width:  |  Height:  |  Size: 142 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: 102 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 102 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: 121 KiB

After

Width:  |  Height:  |  Size: 120 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: 48 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 189 KiB

View file

@ -78,54 +78,49 @@ test.describe("Vendor operations", () => {
.toBeGreaterThanOrEqual(280);
});
test("keeps 48-hour reminders and overlap conflicts reachable from the notification feed", async ({
test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({
page,
}) => {
await authenticate(page);
const workOrder = { kind: "workOrder", tab: "info" };
await page.route("**/api/notifications", (route) =>
await page.route("**/api/vendor-operations/notifications", (route) =>
fulfillJson(route, {
generatedAt: "2026-07-23T12:00:00Z",
sections: [
items: [
{
reason: "no-vendor",
label: "No Vendor",
id: "no-vendor-501",
type: "NoVendor",
severity: "Medium",
count: 1,
items: [
{
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" }],
},
],
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" },
},
{
reason: "vendor-conflict",
label: "Vendor Conflict",
id: "vendor-conflict-801-802",
type: "VendorConflict",
severity: "Medium",
count: 2,
items: [
group: "Vendor Conflict",
title: "Vendor has overlapping appointments",
triggeredAt: "2026-07-23T12:00:00Z",
workOrders: [
{
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" },
],
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",
},
],
actions: [
{ label: "WO-501", href: "/workorders/501" },
{ label: "WO-502", href: "/workorders/502" },
],
},
],
@ -134,19 +129,21 @@ 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: "WO #WO-501" })).toHaveAttribute(
await expect(page.getByRole("link", { name: "Choose vendor" })).toHaveAttribute(
"href",
"/workorders/edit/501?assignVendor=1",
);
await expect(page.getByRole("link", { name: "WO-501" })).toHaveAttribute(
"href",
"/workorders/501",
);
await expect(page.getByRole("link", { name: "WO #WO-502" })).toHaveAttribute(
await expect(page.getByRole("link", { name: "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,7 +30,6 @@ 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,7 +6,6 @@ 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();
@ -26,31 +25,29 @@ export default function AdminShellLayout() {
return (
<ProtectedRoute>
<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 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} />
<Box
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
component="main"
className="flex-1 overflow-auto"
sx={{ backgroundColor: "var(--color-bg-page)" }}
>
<AppTopbar onToggleNav={handleToggleNav} />
<Box
component="main"
className="flex-1 overflow-auto"
sx={{ backgroundColor: "var(--color-bg-page)" }}
>
<Outlet />
</Box>
<Outlet />
</Box>
</Box>
</NotificationSessionProvider>
</Box>
</ProtectedRoute>
);
}

View file

@ -1,26 +1,104 @@
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";
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";
export default function NotificationsPage() {
const center = useNotificationCenter();
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>());
return (
<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 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>
{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

@ -1,27 +0,0 @@
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,27 +2,12 @@ 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) => {
tab?.close();
reportEvidenceError(error);
toast.error(
error instanceof Error ? error.message : "Unable to download evidence right now.",
);
});
}

View file

@ -1,5 +1,4 @@
import { useMemo, type ReactNode } from "react";
import CloseIcon from "@mui/icons-material/Close";
import {
Box,
Button,
@ -8,30 +7,21 @@ 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;
@ -41,122 +31,24 @@ 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>
<Text variant="body">{children}</Text>
</Box>
);
}
function SubsectionTitle({ children }: { children: ReactNode }) {
return (
<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", 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();
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
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}
<Box>
<Text variant="heading" className="mb-1 block">
{title}
</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 sx={{ display: "grid", gap: 1.5 }}>{children}</Box>
</Box>
);
}
@ -175,61 +67,142 @@ function UpliftAttachments({
</Text>
);
}
const canOpen = item.evidenceDocumentId != null;
const extraCount =
typeof item.attachmentCount === "number" && item.attachmentCount > 1
? item.attachmentCount - 1
: 0;
return (
<Box
data-testid="uplift-attachments"
sx={{ display: "flex", flexWrap: "nowrap", gap: 1, overflowX: "auto", pb: 0.5 }}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, flexWrap: "wrap" }}>
<Chip
variant="outlined"
size="small"
label={item.evidenceFileName || "Attachment"}
title={canOpen ? "Opens in a new tab" : undefined}
onClick={canOpen ? () => onOpenAttachment(item) : undefined}
clickable={canOpen}
sx={{ flexShrink: 0, maxWidth: 176 }}
onClick={item.evidenceDocumentId != null ? () => onOpenAttachment(item) : undefined}
clickable={item.evidenceDocumentId != null}
/>
{extraCount > 0 && (
<Chip variant="outlined" label={`+${extraCount}`} sx={{ flexShrink: 0 }} />
)}
{extraCount > 0 && <Chip size="small" label={`+${extraCount}`} />}
</Box>
);
}
function UpliftRequestSection({
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({
item,
breakdown,
breakdownUnavailable,
onOpenAttachment,
}: {
item: UpliftQueueItem;
breakdown: ExposureBreakdown;
breakdownUnavailable: boolean;
onOpenAttachment: (row: UpliftQueueItem) => void;
}) {
const waitingClass = item.requestedAt
? getWaitTimeTextClass(waitTimeColor(item.requestedAt))
: undefined;
return (
<Box component="section" aria-label="Uplift request">
<Text variant="heading" className="mb-2 block">
Uplift request
<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 : "—"}
</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>
);
}
@ -250,19 +223,6 @@ 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
@ -276,61 +236,73 @@ 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 RevokeModalAction({
function UpliftModalActions({
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 (
<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>
<>
{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>
)}
</>
);
}
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({
export function UpliftDetailModal({
item,
canRevoke,
approvePending,
@ -340,102 +312,98 @@ function UpliftDetailContent({
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) {
if (item == null) {
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) {
return null;
}
return (
<Dialog open onClose={rest.onClose} maxWidth="sm" fullWidth aria-label="Uplift request details">
<UpliftDetailContent item={item} {...rest} />
<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>
);
}

View file

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

View file

@ -1,4 +1,3 @@
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useMemo, useState } from "react";
import type {
WorkOrderTablePatch,
@ -20,8 +19,6 @@ 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;
@ -30,33 +27,26 @@ 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;
};
function useSlideOverInitialization({
export function useSlideOverEditState({
row,
editMode,
initialTab,
infoSource,
onSave,
onClose,
closeDisabled,
setTab,
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>;
}) {
}: 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.
useEffect(() => {
if (!row) {
setDraft(null);
@ -69,50 +59,12 @@ function useSlideOverInitialization({
setDraft(row);
setBaseline(row);
setEditing(editMode && !isSlideOverInfoLocked(row.status));
setTab(editMode ? "info" : initialTab);
setTab("info");
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);
@ -143,7 +95,8 @@ export function useSlideOverEditState({
});
}, [infoSource, editing, baseline]);
const [activeDraft, dirtyBaseline] = [draft ?? infoSource, baseline ?? infoSource];
const activeDraft = draft ?? infoSource;
const dirtyBaseline = baseline ?? infoSource;
const isDirty = useMemo(() => {
if (!dirtyBaseline || !activeDraft || !editing) return false;
return isSlideOverDraftDirty(activeDraft, dirtyBaseline);

View file

@ -34,8 +34,6 @@ 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;
@ -92,8 +90,6 @@ export function useWorkOrderSlideOver({
onSave,
onClose,
saving,
initialTab,
openRequestKey,
}: UseWorkOrderSlideOverArgs) {
const { user } = useAuthContext();
const workOrderId = row?.id;
@ -142,8 +138,6 @@ 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, type SlideOverTab } from "./use-work-order-slide-over";
import { useWorkOrderSlideOver } from "./use-work-order-slide-over";
import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav";
import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
@ -35,8 +35,6 @@ type WorkOrderSlideOverProps = {
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onClose: () => void;
saving?: boolean;
initialTab?: SlideOverTab;
openRequestKey?: number;
};
export function WorkOrderSlideOver({
@ -54,18 +52,8 @@ export function WorkOrderSlideOver({
onDuplicateFound,
onClose,
saving,
initialTab,
openRequestKey,
}: WorkOrderSlideOverProps) {
const state = useWorkOrderSlideOver({
row,
editMode,
onSave,
onClose,
saving,
initialTab,
openRequestKey,
});
const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving });
const { infoSource, activeDraft } = state;
return (

View file

@ -51,8 +51,6 @@ 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,19 +18,17 @@ 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(searchParams.get("fromDashboard") === "1");
setFromDashboard(true);
onApplyRef.current(drilldown);
setSearchParams({}, { replace: true });
}, [searchParams, setSearchParams]);

View file

@ -1,42 +0,0 @@
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,7 +1,6 @@
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,
@ -18,45 +17,10 @@ import { guardDialogClose } from "@/lib/dialog-guard";
import { useWorkOrdersListFilters } from "./use-work-orders-list-filters";
import { useWorkOrdersTableData } from "./use-work-orders-table-data";
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 [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
const [slideOverEdit, setSlideOverEdit] = useState(false);
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null);
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
@ -110,6 +74,11 @@ 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);
@ -161,8 +130,6 @@ export function useWorkOrdersListPage() {
wizardOpen,
setWizardOpen,
slideOverEdit,
slideOverTab,
slideOverOpenRequestKey,
confirmCancel,
setConfirmCancel,
confirmComplete,

View file

@ -10,7 +10,6 @@ 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";
@ -28,7 +27,6 @@ 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

@ -1,189 +0,0 @@
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,14 +1,49 @@
import { AppBar, Box, Toolbar } from "@mui/material";
import { Menu as MenuIcon } from "lucide-react";
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 { AppTopbarUserMenu } from "@/components/layout/app-topbar-user-menu";
import { NotificationBell } from "@/components/notifications/notification-bell";
import { useAuthContext } from "@/providers/auth-context";
import { getPrimaryUserRole } from "@/lib/auth/user-utils";
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"
@ -62,10 +97,135 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
<Box sx={{ flex: 1 }} />
<NotificationBell />
<AppTopbarUserMenu />
<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>
<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>
</AppBar>
);
}

View file

@ -1,98 +0,0 @@
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

@ -1,33 +0,0 @@
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

@ -1,89 +0,0 @@
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

@ -1,127 +0,0 @@
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

@ -1,47 +0,0 @@
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,15 +14,8 @@ export const OPEN_WIZARD_STATUSES: readonly string[] = ALL_WIZARD_STATUSES.filte
(status) => status !== "Completed",
);
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();
export function workOrderDrilldownUrl(search: URLSearchParams): string {
const query = search.toString();
return query ? `${WORK_ORDERS_ROUTE}?${query}` : WORK_ORDERS_ROUTE;
}

View file

@ -1,10 +0,0 @@
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

@ -1,18 +0,0 @@
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

@ -1,38 +0,0 @@
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

@ -1,62 +0,0 @@
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

@ -1,16 +0,0 @@
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

@ -1,46 +0,0 @@
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

@ -1,60 +0,0 @@
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,61 +106,28 @@ export const upliftsApi = {
id: string | number,
fallbackFileName = "uplift-evidence",
): Promise<void> => {
const response = await fetchEvidence(id, "upliftsApi.downloadEvidence");
const blob = await response.blob();
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;
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 url = URL.createObjectURL(new Blob([blob], { type }));
tab.location.href = url;
window.setTimeout(() => URL.revokeObjectURL(url), EVIDENCE_URL_TTL_MS);
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);
},
};
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,9 +116,6 @@ 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,9 +46,6 @@ 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,6 +5,7 @@ import type {
VendorAvailability,
VendorAssignmentPayload,
VendorInsight,
VendorNotification,
} from "@/domain/vendor-operations/types/vendor-operations";
const base = "vendor-operations";
@ -42,6 +43,13 @@ 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,3 +1,28 @@
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

@ -1,81 +0,0 @@
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,8 +1,12 @@
import { API_PATHS } from "@/api/api-paths";
import { apiDelete, apiGet, apiPost, apiPostForm, apiPut } from "@/api/api";
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
import { apiDelete, apiGet, apiPost, apiPostForm, apiPut, apiRequestRaw } 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,
@ -16,6 +20,7 @@ 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";
@ -37,6 +42,7 @@ import type {
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import type {
WorkOrderBoardListResult,
WorkOrderBoardPatchPayload,
WorkOrderBoardQueryParams,
WorkOrderBoardResponseApi,
WorkOrderBoardSearchQueryParams,
@ -304,10 +310,51 @@ export const workOrdersApi = {
return mapWorkOrderDetail(handleApiResponse(data));
},
patchBoardField: workOrderBoardPatchApi.patchBoardField,
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",
);
/** Manual POC override — same row/error contract as the board patch. */
updatePoc: workOrderBoardPatchApi.updatePoc,
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);
},
update: async (
id: string | number,

View file

@ -28,6 +28,9 @@ const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
"techPhone",
"location",
"locationId",
"pocName",
"pocPhone",
"pocNotes",
"title",
"priority",
"apptStart",
@ -50,44 +53,16 @@ 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 ops (one request each).
* Schedule/week-only fields are emitted first in a stable order; the composite
* POC op follows, then scalar fields.
* Expands a UI table patch into ordered board field ops (one PATCH each).
* Schedule/week-only fields are emitted first in a stable order.
*/
export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardOp[] {
export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardFieldOp[] {
if (isLocalOnlyTablePatch(patch)) {
return [];
}
return [...expandScheduleBoardOps(patch), ...expandPocOp(patch), ...expandScalarBoardOps(patch)];
return [...expandScheduleBoardOps(patch), ...expandScalarBoardOps(patch)];
}
export function isDispatchBoardField(field: WorkOrderBoardEditableField): boolean {

View file

@ -134,14 +134,6 @@ 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,7 +14,6 @@ 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 {
@ -54,30 +53,6 @@ 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,
@ -97,7 +72,7 @@ async function applyBoardFieldOps(
let completedOps = 0;
for (const op of ops) {
try {
latest = await applyBoardOp(id, op, latest);
latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
completedOps += 1;
} catch (error) {
if (completedOps > 0) {

View file

@ -7,8 +7,7 @@ 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 persist via the dedicated POC endpoint;
* tech phone is not board-editable — exclude so Save cannot no-op.
* Primary POC scalars and tech phone are not board-editable — exclude so Save cannot no-op.
* additionalContacts is board-editable (SH-218 PATCH field).
*/
export const SLIDE_OVER_EDIT_KEYS = [
@ -33,9 +32,6 @@ 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

@ -1,48 +0,0 @@
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,10 +149,6 @@ 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,7 +80,6 @@ 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";
@ -162,7 +161,7 @@ describe("DashboardPage", () => {
const today = businessTodayIso();
expect(navigate).toHaveBeenCalledWith(
workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch(today)),
`/workorders?${scheduledTomorrowDrilldownSearch(today).toString()}`,
);
fireEvent.click(screen.getByRole("button", { name: /Pending Uplifts/ }));

View file

@ -41,9 +41,6 @@ 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, within } from "@testing-library/react";
import { screen, waitFor } 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,9 +80,6 @@ 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,
@ -116,31 +113,17 @@ describe("UpliftDetailModal permission failures (Dispatcher 403)", () => {
woUpliftsMock.calls = 0;
});
it("renders the work order from the queue item without a work-order fetch or error toast", async () => {
it("shows unavailable sections without an error toast and keeps Revoke admin-only", 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();
});
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();
});
}
await waitFor(() => {
expect(screen.getAllByText("Unavailable").length).toBeGreaterThanOrEqual(3);
});
const revoke = screen.getByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();

View file

@ -1,13 +1,18 @@
import { fireEvent, screen, within } from "@testing-library/react";
import { fireEvent, screen } 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, isError: false }),
useWorkOrderUplifts: () => ({ data: woUplifts.data, isLoading: false, error: null }),
}));
const canApproveState = vi.hoisted(() => ({ data: true as boolean | undefined }));
@ -23,7 +28,7 @@ const baseItem: UpliftQueueItem = {
requestedNTE: 250,
delta: 150,
vendorReason: "",
requestedAt: "2026-01-15T15:00:00Z",
requestedAt: "2026-01-15T10:00:00Z",
requestedByVendorName: "Gateway",
decidedAt: "",
decidedByName: "",
@ -45,9 +50,6 @@ const baseItem: UpliftQueueItem = {
woNumber: "WO-99",
site: "Site A",
serviceName: "Plumbing repair",
technicianName: "",
workOrderDispatcherName: "",
workOrderScheduledDate: "",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
@ -56,150 +58,76 @@ const baseItem: UpliftQueueItem = {
};
function renderModal(overrides: Partial<UpliftQueueItem> = {}, canRevoke = true) {
const callbacks = {
onClose: vi.fn(),
onApprove: vi.fn(),
onReject: vi.fn(),
onRevoke: vi.fn(),
onOpenAttachment: vi.fn(),
};
const onApprove = vi.fn();
const onReject = vi.fn();
const onRevoke = vi.fn();
const onOpenAttachment = vi.fn();
renderWithProviders(
<UpliftDetailModal
item={{ ...baseItem, ...overrides }}
canRevoke={canRevoke}
approvePending={false}
revokePending={false}
{...callbacks}
onClose={vi.fn()}
onApprove={onApprove}
onReject={onReject}
onRevoke={onRevoke}
onOpenAttachment={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 ?? "";
return { onApprove, onReject, onRevoke, onOpenAttachment };
}
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("organizes content into Work order and Uplift request divided by a single rule", () => {
it("shows work order and request placeholders for a pending uplift", () => {
renderModal();
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();
expect(screen.getByText("Unscheduled")).toBeInTheDocument();
expect(screen.getByText("No justification provided.")).toBeInTheDocument();
expect(screen.getByText("No attachments")).toBeInTheDocument();
});
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", () => {
it("opens an evidence attachment through the provided callback", () => {
const { onOpenAttachment } = renderModal({
evidenceDocumentId: "document-1",
evidenceFileName: "quote.pdf",
attachmentCount: 3,
attachmentCount: 1,
});
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"));
fireEvent.click(screen.getByText("quote.pdf"));
expect(onOpenAttachment).toHaveBeenCalledWith(
expect.objectContaining({ evidenceDocumentId: "document-1" }),
);
});
it("shows the approved-on-WO exposure breakdown inside the uplift request", () => {
it("falls back to the work order uplifts for the approved-on-WO breakdown", () => {
renderModal();
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();
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();
});
it("offers Reject then Approve for a pending uplift", () => {
it("offers status-specific actions 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();
});
@ -220,17 +148,12 @@ describe("UpliftDetailModal", () => {
expect(onReject).not.toHaveBeenCalled();
});
it("offers Revoke for an approved uplift and disables it with a tooltip on a closed work order", async () => {
it("offers Revoke for an approved uplift and disables it on a closed work order", () => {
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();
});
@ -246,26 +169,6 @@ 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,9 +42,6 @@ const pendingItem: UpliftQueueItem = {
woNumber: "WO-99",
site: "Site A",
serviceName: "Plumbing repair",
technicianName: "",
workOrderDispatcherName: "",
workOrderScheduledDate: "",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,

View file

@ -2,7 +2,6 @@ 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,
@ -112,34 +111,6 @@ 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

@ -1,137 +0,0 @@
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] as { field: string });
const patched = await patchBoardField(item.id, ops[0]!);
setRow({ ...patched, dispatcherId: id, dispatcherName: name });
}}
/>

View file

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

View file

@ -42,53 +42,4 @@ 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,25 +108,9 @@ 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,100 +658,6 @@ 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,12 +34,6 @@ 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", () => {
@ -191,33 +185,7 @@ describe("expandTablePatchToBoardFieldOps", () => {
pocPhone: "111",
pocNotes: "call",
}),
).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" },
]);
).toEqual([{ field: "siteCode", value: "BK5" }]);
});
it("keeps dueDate and scheduledDate patches independent", () => {

View file

@ -9,14 +9,12 @@ 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),
},
}));
@ -123,7 +121,6 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
beforeEach(() => {
getById.mockReset();
update.mockReset();
updatePoc.mockReset();
patchBoardField.mockReset();
serverState = { ...BASE_SERVER };
rowState = { ...BASE_ROW };
@ -832,40 +829,6 @@ 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,9 +67,7 @@ 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).toContain("pocName");
expect(SLIDE_OVER_EDIT_KEYS).toContain("pocPhone");
expect(SLIDE_OVER_EDIT_KEYS).toContain("pocNotes");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("pocName");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("techPhone");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("company");
@ -87,11 +85,11 @@ describe("slide-over-draft", () => {
expect(buildSlideOverPatch(vendorDraft, row)).toEqual({ vendorId: "v2" });
});
it("tracks POC edits for dirty state and patch", () => {
it("ignores POC-only edits for dirty state", () => {
const row = baseRow();
const draft = baseRow({ pocName: "Jordan", pocPhone: "555-0100" });
expect(isSlideOverDraftDirty(draft, row)).toBe(true);
expect(buildSlideOverPatch(draft, row)).toEqual({ pocName: "Jordan", pocPhone: "555-0100" });
expect(isSlideOverDraftDirty(draft, row)).toBe(false);
expect(buildSlideOverPatch(draft, row)).toEqual({});
});
it("detects dirty draft when a tracked field changes", () => {

View file

@ -0,0 +1,43 @@
## 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

@ -0,0 +1,39 @@
# 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

@ -0,0 +1,27 @@
## 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

@ -0,0 +1,41 @@
# 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`.