Compare commits
15 commits
73e3362fcf
...
3e5fba4d2b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e5fba4d2b | ||
|
|
cbfed3dde8 | ||
|
|
f25a3dd628 | ||
|
|
41b26692c3 | ||
| 1698d53d38 | |||
|
|
84568dd737 | ||
|
|
3b1da21abb | ||
|
|
5b0c758a9f | ||
|
|
8b122ad71c | ||
|
|
062a1e26c8 | ||
|
|
edd2e7efe2 | ||
|
|
988c96c42d | ||
|
|
33d0ab4fd6 | ||
|
|
dcf944d7dc | ||
|
|
3c84d851c0 |
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!--
|
||||
Title: type(scope): description (SH-123)
|
||||
type is one of feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert, release.
|
||||
Product work carries its SH key at the end of the title. Platform or security work carries PLAT or SEC.
|
||||
Docs-only and configuration-only chores may omit the key.
|
||||
Branch: feature/, fix/, hotfix/, chore/, docs/, refactor/, release/ plus a kebab-case description. No Jira keys in branch names.
|
||||
Scope: one logical change per PR. If the title needs "and", split it.
|
||||
Body: verifiable facts about the change. No validation transcripts, no deployment notes, no AI attribution footers.
|
||||
The layout below is this repository's contract (REVIEW_AND_PR_FRAMEWORK.md, section 8). Keep the three headings.
|
||||
-->
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- What changed and why, in plain language. Two to four sentences. -->
|
||||
|
||||
## Changes and value
|
||||
|
||||
<!-- Grouped by area, each with the value it delivers. Bullets, bold lead-in per bullet. -->
|
||||
|
||||
## Ticket
|
||||
|
||||
<!-- The board key(s) this PR delivers, one per line. "None" when nothing applies. -->
|
||||
3
.gitignore
vendored
|
|
@ -49,3 +49,6 @@ seed-data.sql
|
|||
# python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# local scratch
|
||||
/tmp/
|
||||
|
|
|
|||
20
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# SHOC Frontend (`shoc-frontend-new`)
|
||||
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml)
|
||||

|
||||

|
||||
|
|
@ -13,7 +13,8 @@ the legacy SHOC frontend — new code follows the IrisLoan.Admin conventions
|
|||
documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md).
|
||||
|
||||
- **GitHub:** `Sea-Haven-Industries/shoc-frontend-new`
|
||||
- **Hosted at:** <https://dev.seahaven.com> (dev environment; the only environment today)
|
||||
- **Hosted at:** <https://dev.seahaven.com> (dev, deployed from `main`) and
|
||||
<https://staging.seahaven.com> (staging, deployed from `vX.Y.Z-staging` tags)
|
||||
- **Backend API:** `https://api.dev.seahaven.com/api` (called directly, cross-origin) — source: `Sea-Haven-Industries/shoc-backend`
|
||||
|
||||
## Architecture
|
||||
|
|
@ -122,16 +123,19 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
|||
|
||||
## Contributing
|
||||
|
||||
- Branch from `dev` with a kebab-case description and a prefix matching the
|
||||
work: `feature/`, `bug/`, `hotfix/`, `chore/`, `docs/`, or `refactor/`
|
||||
- Branch from `main` with a kebab-case description and a prefix matching the
|
||||
work: `feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, or `refactor/`
|
||||
(e.g. `feature/vendor-portal-filters`, `chore/sea-haven-branding`).
|
||||
- Commit messages follow
|
||||
[Conventional Commits](https://www.conventionalcommits.org) — commitlint
|
||||
rejects anything else at commit time.
|
||||
- Open PRs against `main`. Protected branches need a green CI run and an
|
||||
approving review from a code owner
|
||||
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals.
|
||||
Merged branches are deleted automatically.
|
||||
- Open PRs against `main`. The PR body uses the three-section layout the
|
||||
template pre-fills: Summary, Changes and value, Ticket. `main` needs the
|
||||
`governance` and `Build and test / ci` checks and an approving review from a
|
||||
code owner (`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale
|
||||
approvals. PRs merge through the merge queue, so a branch does not need to
|
||||
be updated with `main` before it merges. Merged branches are deleted
|
||||
automatically.
|
||||
- PRs cannot mix `terraform/` with deployable application files (G13). Workflow,
|
||||
docs, and gate-script changes may travel with either side. `deploy-web.yaml`
|
||||
still ignores `terraform/**` so a Terraform-only merge does not sync the bucket.
|
||||
|
|
|
|||
|
|
@ -90,3 +90,19 @@ that includes both `terraform/` and deployable application files (`src/`,
|
|||
`public/`, `pages/`, `config/`, `index.html`, Vite/tsconfig, or `.env*`)
|
||||
fails G13. Workflow, docs, and gate-script changes may travel with either
|
||||
side.
|
||||
|
||||
## 8. PR description contract (minimal)
|
||||
|
||||
- **Summary** — what changed and why, in plain language.
|
||||
- **Changes and value** — grouped by area, each with the value it delivers.
|
||||
- **Ticket** — the board key(s) when one applies; "None" otherwise.
|
||||
- Link any ADR relied upon.
|
||||
|
||||
Avoid boilerplate: no deployment notes, no validation transcripts, no
|
||||
"residual-risk" theatre, no AI signatures. The validation story lives in the
|
||||
check run results and the close-out, not in the PR body.
|
||||
|
||||
`.github/PULL_REQUEST_TEMPLATE.md` pre-fills this layout and overrides the org
|
||||
template, whose Summary / Validation / Tests / Notes headings this repository
|
||||
does not use. The org `callable-pr-policy` workflow hard-codes those four
|
||||
headings; it is not wired into this repository, and this layout is the reason.
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 191 KiB |
85
e2e/vendors/vendor-operations.spec.ts
vendored
|
|
@ -78,49 +78,54 @@ test.describe("Vendor operations", () => {
|
|||
.toBeGreaterThanOrEqual(280);
|
||||
});
|
||||
|
||||
test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({
|
||||
test("keeps 48-hour reminders and overlap conflicts reachable from the notification feed", async ({
|
||||
page,
|
||||
}) => {
|
||||
await authenticate(page);
|
||||
await page.route("**/api/vendor-operations/notifications", (route) =>
|
||||
const workOrder = { kind: "workOrder", tab: "info" };
|
||||
await page.route("**/api/notifications", (route) =>
|
||||
fulfillJson(route, {
|
||||
items: [
|
||||
generatedAt: "2026-07-23T12:00:00Z",
|
||||
sections: [
|
||||
{
|
||||
id: "no-vendor-501",
|
||||
type: "NoVendor",
|
||||
reason: "no-vendor",
|
||||
label: "No Vendor",
|
||||
severity: "Medium",
|
||||
group: "No Vendor",
|
||||
title: "No vendor assigned — 48h to service",
|
||||
workOrderId: 501,
|
||||
workOrderNumber: "WO-501",
|
||||
triggeredAt: "2026-07-23T12:00:00Z",
|
||||
scheduledAt: "2026-07-25T12:00:00Z",
|
||||
action: { label: "Choose vendor", href: "/workorders/edit/501?assignVendor=1" },
|
||||
},
|
||||
{
|
||||
id: "vendor-conflict-801-802",
|
||||
type: "VendorConflict",
|
||||
severity: "Medium",
|
||||
group: "Vendor Conflict",
|
||||
title: "Vendor has overlapping appointments",
|
||||
triggeredAt: "2026-07-23T12:00:00Z",
|
||||
workOrders: [
|
||||
count: 1,
|
||||
items: [
|
||||
{
|
||||
workOrderId: 501,
|
||||
workOrderNumber: "WO-501",
|
||||
start: "2026-07-25T12:00:00Z",
|
||||
end: "2026-07-25T14:00:00Z",
|
||||
},
|
||||
{
|
||||
workOrderId: 502,
|
||||
workOrderNumber: "WO-502",
|
||||
start: "2026-07-25T13:00:00Z",
|
||||
end: "2026-07-25T15:00:00Z",
|
||||
id: "no-vendor-501",
|
||||
reason: "no-vendor",
|
||||
severity: "Medium",
|
||||
rowType: "dismissable",
|
||||
title: "WO #WO-501 starts within 48h with no vendor assigned",
|
||||
count: 1,
|
||||
triggeredAt: "2026-07-23T12:00:00Z",
|
||||
target: { ...workOrder, workOrderId: 501 },
|
||||
workOrders: [{ id: 501, number: "WO-501" }],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{ label: "WO-501", href: "/workorders/501" },
|
||||
{ label: "WO-502", href: "/workorders/502" },
|
||||
},
|
||||
{
|
||||
reason: "vendor-conflict",
|
||||
label: "Vendor Conflict",
|
||||
severity: "Medium",
|
||||
count: 2,
|
||||
items: [
|
||||
{
|
||||
id: "vendor-conflict-501-502",
|
||||
reason: "vendor-conflict",
|
||||
severity: "Medium",
|
||||
rowType: "dismissable",
|
||||
title: "Acme has overlapping appointments",
|
||||
count: 2,
|
||||
triggeredAt: "2026-07-23T12:00:00Z",
|
||||
target: { ...workOrder, workOrderId: 501 },
|
||||
workOrders: [
|
||||
{ id: 501, number: "WO-501" },
|
||||
{ id: 502, number: "WO-502" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
@ -129,21 +134,19 @@ test.describe("Vendor operations", () => {
|
|||
|
||||
await page.goto("/notifications");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Items requiring attention" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "No Vendor" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Vendor Conflict" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Choose vendor" })).toHaveAttribute(
|
||||
"href",
|
||||
"/workorders/edit/501?assignVendor=1",
|
||||
);
|
||||
await expect(page.getByRole("link", { name: "WO-501" })).toHaveAttribute(
|
||||
await expect(page.getByRole("link", { name: "WO #WO-501" })).toHaveAttribute(
|
||||
"href",
|
||||
"/workorders/501",
|
||||
);
|
||||
await expect(page.getByRole("link", { name: "WO-502" })).toHaveAttribute(
|
||||
await expect(page.getByRole("link", { name: "WO #WO-502" })).toHaveAttribute(
|
||||
"href",
|
||||
"/workorders/502",
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: /starts within 48h with no vendor assigned/ }).click();
|
||||
await expect(page).toHaveURL(/\/workorders\/edit\/501\?assignVendor=1$/);
|
||||
});
|
||||
|
||||
test("filters insights and exposes both export formats", async ({ page }) => {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export const API_PATHS = {
|
|||
boardCreate: "workorders/board",
|
||||
boardSearch: "workorders/board/search",
|
||||
boardPatch: (id: string | number) => `workorders/${id}/board`,
|
||||
pocPatch: (id: string | number) => `workorders/${id}/poc`,
|
||||
boardDetail: (id: string | number) => `workorders/${id}/detail`,
|
||||
completionTemplates: "workorders/completion-templates",
|
||||
comments: (id: string | number) => `workorders/${id}/comments`,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { ProtectedRoute } from "@/components/auth/protected-route";
|
|||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||
import { getSidebarWidth } from "@/config/menu";
|
||||
import { AppTopbar } from "@/components/layout/app-topbar";
|
||||
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
|
||||
|
||||
export default function AdminShellLayout() {
|
||||
const theme = useTheme();
|
||||
|
|
@ -25,29 +26,31 @@ export default function AdminShellLayout() {
|
|||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<Box className="flex h-screen w-full overflow-hidden bg-background">
|
||||
<AppSidebar
|
||||
collapsed={isDesktop ? navCollapsed : false}
|
||||
mobile={!isDesktop}
|
||||
open={isDesktop || mobileNavOpen}
|
||||
onClose={() => setMobileNavOpen(false)}
|
||||
/>
|
||||
|
||||
<Box
|
||||
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
|
||||
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
|
||||
>
|
||||
<AppTopbar onToggleNav={handleToggleNav} />
|
||||
<NotificationSessionProvider>
|
||||
<Box className="flex h-screen w-full overflow-hidden bg-background">
|
||||
<AppSidebar
|
||||
collapsed={isDesktop ? navCollapsed : false}
|
||||
mobile={!isDesktop}
|
||||
open={isDesktop || mobileNavOpen}
|
||||
onClose={() => setMobileNavOpen(false)}
|
||||
/>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
className="flex-1 overflow-auto"
|
||||
sx={{ backgroundColor: "var(--color-bg-page)" }}
|
||||
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
|
||||
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
|
||||
>
|
||||
<Outlet />
|
||||
<AppTopbar onToggleNav={handleToggleNav} />
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
className="flex-1 overflow-auto"
|
||||
sx={{ backgroundColor: "var(--color-bg-page)" }}
|
||||
>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</NotificationSessionProvider>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,104 +1,26 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { Link } from "react-router";
|
||||
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
|
||||
import { Box } from "@mui/material";
|
||||
import { NotificationFeedActions } from "@/components/notifications/notification-feed-actions";
|
||||
import { NotificationFeedList } from "@/components/notifications/notification-feed-list";
|
||||
import { useNotificationCenter } from "@/components/notifications/use-notification-center";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const {
|
||||
data = [],
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["vendor-operations", "notifications"],
|
||||
queryFn: vendorOperationsApi.notifications,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const groups = data.reduce((result, item) => {
|
||||
const items = result.get(item.group) ?? [];
|
||||
items.push(item);
|
||||
result.set(item.group, items);
|
||||
return result;
|
||||
}, new Map<string, typeof data>());
|
||||
const center = useNotificationCenter();
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
Items requiring attention
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Medium-severity vendor reminders refresh automatically. They do not create banners or
|
||||
toasts.
|
||||
</Typography>
|
||||
<Box className="flex flex-col gap-2 p-2.5">
|
||||
<PageHeader
|
||||
surface
|
||||
title="Notifications"
|
||||
actions={
|
||||
<div className="flex items-center gap-3">
|
||||
<NotificationFeedActions center={center} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Box className="max-w-3xl px-5 pb-5">
|
||||
<NotificationFeedList center={center} />
|
||||
</Box>
|
||||
{isLoading && <CircularProgress aria-label="Loading notifications" />}
|
||||
{Boolean(error) && <Alert severity="error">{error?.message}</Alert>}
|
||||
{!isLoading && !error && data.length === 0 && (
|
||||
<Paper variant="outlined" className="p-6">
|
||||
<Typography>No vendor alerts require attention.</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
{[...groups.entries()].map(([group, items]) => (
|
||||
<Paper key={group} variant="outlined" className="p-4">
|
||||
<Stack direction="row" spacing={1} className="mb-3" sx={{ alignItems: "center" }}>
|
||||
<Typography variant="h6">{group}</Typography>
|
||||
<Chip label={items.length} size="small" />
|
||||
</Stack>
|
||||
<Stack spacing={2}>
|
||||
{items.map((item) => (
|
||||
<Paper key={item.id} variant="outlined" className="p-3">
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
sx={{ gap: 2, justifyContent: "space-between" }}
|
||||
>
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<Chip label={item.severity} color="warning" size="small" />
|
||||
<Typography sx={{ fontWeight: 600 }}>{item.title}</Typography>
|
||||
</Stack>
|
||||
<Typography color="text.secondary" className="mt-1">
|
||||
{item.workOrderNumber ??
|
||||
item.workOrders?.map((workOrder) => workOrder.workOrderNumber).join(" ↔ ")}
|
||||
</Typography>
|
||||
{item.scheduledAt != null && (
|
||||
<Typography variant="caption">
|
||||
Scheduled {new Date(item.scheduledAt).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
{item.action != null && (
|
||||
<Button component={Link} to={item.action.href} variant="contained">
|
||||
{item.action.label}
|
||||
</Button>
|
||||
)}
|
||||
{item.actions?.map((action) => (
|
||||
<Button
|
||||
key={action.href}
|
||||
component={Link}
|
||||
to={action.href}
|
||||
variant="outlined"
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
const DATE_FORMAT: Intl.DateTimeFormatOptions = {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
};
|
||||
|
||||
/** Formats an API UTC instant as local "Jan 15, 2026 · 10:00 AM"; "" when absent or invalid. */
|
||||
export function formatUpliftDateTime(value: string): string {
|
||||
if (!value) return "";
|
||||
const hasZone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value);
|
||||
const date = new Date(hasZone ? value : `${value}Z`);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const time = date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
|
||||
return `${date.toLocaleDateString("en-US", DATE_FORMAT)} · ${time}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a work-order schedule as "Apr 10, 2026". The schedule is a calendar day,
|
||||
* so only its date part is read and it never shifts with the viewer's time zone.
|
||||
*/
|
||||
export function formatUpliftCalendarDate(value: string): string {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
|
||||
if (match == null) return "";
|
||||
const [, year, month, day] = match;
|
||||
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
|
||||
return date.toLocaleDateString("en-US", { ...DATE_FORMAT, timeZone: "UTC" });
|
||||
}
|
||||
|
|
@ -2,12 +2,27 @@ import { toast } from "react-toastify";
|
|||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
|
||||
|
||||
function reportEvidenceError(error: unknown): void {
|
||||
toast.error(error instanceof Error ? error.message : "Unable to download evidence right now.");
|
||||
}
|
||||
|
||||
export function openUpliftEvidence(row: UpliftQueueItem): void {
|
||||
void upliftsApi
|
||||
.downloadEvidence(row.id, row.evidenceFileName || "uplift-evidence")
|
||||
.catch(reportEvidenceError);
|
||||
}
|
||||
|
||||
export function openUpliftEvidenceInNewTab(row: UpliftQueueItem): void {
|
||||
// Open the tab inside the click handler; a tab opened after the fetch resolves
|
||||
// is treated as an unsolicited popup and blocked.
|
||||
const tab = window.open("about:blank", "_blank");
|
||||
if (tab != null) {
|
||||
tab.opener = null;
|
||||
}
|
||||
void upliftsApi
|
||||
.openEvidence(row.id, tab, row.evidenceFileName || "uplift-evidence")
|
||||
.catch((error: unknown) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Unable to download evidence right now.",
|
||||
);
|
||||
tab?.close();
|
||||
reportEvidenceError(error);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useMemo, type ReactNode } from "react";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
|
|
@ -7,21 +8,30 @@ import {
|
|||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
formatUpliftCalendarDate,
|
||||
formatUpliftDateTime,
|
||||
} from "@/app/(protected)/uplifts/_components/format-uplift-dates";
|
||||
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import { useWorkOrderUplifts } from "@/domain/work-orders/use-cases/use-work-order-uplifts";
|
||||
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
|
||||
import { isWorkOrderUpliftsReadOnly } from "@/domain/work-orders/utils/uplift-display-utils";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||
import { getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||
|
||||
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
|
||||
const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
|
||||
const UNAVAILABLE_LABEL = "Unavailable";
|
||||
const BORDERED_BLOCK_SX = {
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
px: 1.75,
|
||||
py: 1.25,
|
||||
} as const;
|
||||
|
||||
type ModalCallbacks = {
|
||||
onClose: () => void;
|
||||
|
|
@ -31,24 +41,122 @@ type ModalCallbacks = {
|
|||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||
};
|
||||
|
||||
type ExposureBreakdown = { auto: number | null; admin: number | null; total: number | null };
|
||||
|
||||
function DetailField({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<Box>
|
||||
<Text variant="label" tone="muted" className="block">
|
||||
{label}
|
||||
</Text>
|
||||
{children}
|
||||
<Text variant="body">{children}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
function SubsectionTitle({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Box>
|
||||
<Text variant="heading" className="mb-1 block">
|
||||
{title}
|
||||
<Text variant="label" as="h4" className="mb-1.5 mt-4 block">
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function vendorTechnicianLabel(item: UpliftQueueItem): string {
|
||||
const parts = [item.vendorCompanyName.trim(), item.technicianName.trim()].filter(Boolean);
|
||||
const unique = parts.filter((part, index) => parts.indexOf(part) === index);
|
||||
return unique.length > 0 ? unique.join(" · ") : "Not assigned";
|
||||
}
|
||||
|
||||
function WorkOrderSection({ item }: { item: UpliftQueueItem }) {
|
||||
return (
|
||||
<Box component="section" aria-label="Work order">
|
||||
<Text variant="heading" className="mb-2 block">
|
||||
Work order
|
||||
</Text>
|
||||
<Box sx={{ display: "grid", gap: 1.5 }}>{children}</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.75 }}>
|
||||
<DetailField label="Service">{item.serviceName || "Not specified"}</DetailField>
|
||||
<DetailField label="Vendor / Technician">{vendorTechnicianLabel(item)}</DetailField>
|
||||
<DetailField label="Assigned Dispatcher">
|
||||
{item.workOrderDispatcherName || "Unassigned"}
|
||||
</DetailField>
|
||||
<DetailField label="Scheduled">
|
||||
{formatUpliftCalendarDate(item.workOrderScheduledDate) || "Unscheduled"}
|
||||
</DetailField>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function JustificationBlock({ notes }: { notes: string }) {
|
||||
const text = notes.trim();
|
||||
return (
|
||||
<Box sx={{ ...BORDERED_BLOCK_SX, minHeight: 60 }} data-testid="uplift-justification">
|
||||
{text.length > 0 && <Text variant="body">{text}</Text>}
|
||||
{text.length === 0 && (
|
||||
<Text variant="description" tone="muted" className="italic">
|
||||
No justification provided.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function BreakdownRow({
|
||||
label,
|
||||
value,
|
||||
strong,
|
||||
unavailable,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | null;
|
||||
strong: boolean;
|
||||
unavailable: boolean;
|
||||
}) {
|
||||
const fallback = unavailable ? UNAVAILABLE_LABEL : "—";
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
...(strong ? { borderTop: 1, borderColor: "divider", mt: 1, pt: 1 } : { py: 0.25 }),
|
||||
}}
|
||||
>
|
||||
<Text variant={strong ? "body" : "description"} tone={strong ? "default" : "muted"}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
variant={strong ? "body" : "description"}
|
||||
className={strong ? "font-medium" : undefined}
|
||||
>
|
||||
{value != null ? formatUpliftMoney(value) : fallback}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovedOnWoBreakdown({
|
||||
breakdown,
|
||||
unavailable,
|
||||
}: {
|
||||
breakdown: ExposureBreakdown;
|
||||
unavailable: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Box sx={BORDERED_BLOCK_SX}>
|
||||
<BreakdownRow
|
||||
label="Auto-approved (within allowance)"
|
||||
value={breakdown.auto}
|
||||
strong={false}
|
||||
unavailable={unavailable}
|
||||
/>
|
||||
<BreakdownRow
|
||||
label="Admin-approved"
|
||||
value={breakdown.admin}
|
||||
strong={false}
|
||||
unavailable={unavailable}
|
||||
/>
|
||||
<BreakdownRow label="Total" value={breakdown.total} strong unavailable={unavailable} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -67,142 +175,61 @@ function UpliftAttachments({
|
|||
</Text>
|
||||
);
|
||||
}
|
||||
const canOpen = item.evidenceDocumentId != null;
|
||||
const extraCount =
|
||||
typeof item.attachmentCount === "number" && item.attachmentCount > 1
|
||||
? item.attachmentCount - 1
|
||||
: 0;
|
||||
return (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, flexWrap: "wrap" }}>
|
||||
<Box
|
||||
data-testid="uplift-attachments"
|
||||
sx={{ display: "flex", flexWrap: "nowrap", gap: 1, overflowX: "auto", pb: 0.5 }}
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={item.evidenceFileName || "Attachment"}
|
||||
onClick={item.evidenceDocumentId != null ? () => onOpenAttachment(item) : undefined}
|
||||
clickable={item.evidenceDocumentId != null}
|
||||
title={canOpen ? "Opens in a new tab" : undefined}
|
||||
onClick={canOpen ? () => onOpenAttachment(item) : undefined}
|
||||
clickable={canOpen}
|
||||
sx={{ flexShrink: 0, maxWidth: 176 }}
|
||||
/>
|
||||
{extraCount > 0 && <Chip size="small" label={`+${extraCount}`} />}
|
||||
{extraCount > 0 && (
|
||||
<Chip variant="outlined" label={`+${extraCount}`} sx={{ flexShrink: 0 }} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkOrderSection({
|
||||
item,
|
||||
info,
|
||||
unavailable,
|
||||
}: {
|
||||
item: UpliftQueueItem;
|
||||
info?: WorkOrderTableRow;
|
||||
unavailable: boolean;
|
||||
}) {
|
||||
const statusFallback = unavailable ? UNAVAILABLE_LABEL : "—";
|
||||
return (
|
||||
<DetailSection title="Work order">
|
||||
<DetailField label="Work order #">
|
||||
<Text variant="body">{item.woNumber || "—"}</Text>
|
||||
</DetailField>
|
||||
<DetailField label="Site">
|
||||
<Text variant="body">{info?.site || item.site || "—"}</Text>
|
||||
</DetailField>
|
||||
<DetailField label="Status">
|
||||
<Text variant="body">{info?.status || statusFallback}</Text>
|
||||
</DetailField>
|
||||
<DetailField label="Scheduled">
|
||||
<Text variant="body">
|
||||
{info?.scheduledOn || (unavailable ? UNAVAILABLE_LABEL : "Unscheduled")}
|
||||
</Text>
|
||||
</DetailField>
|
||||
<DetailField label="Vendor">
|
||||
<Text variant="body">{item.vendorCompanyName || "—"}</Text>
|
||||
</DetailField>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestSection({
|
||||
function UpliftRequestSection({
|
||||
item,
|
||||
breakdown,
|
||||
breakdownUnavailable,
|
||||
onOpenAttachment,
|
||||
}: {
|
||||
item: UpliftQueueItem;
|
||||
breakdown: ExposureBreakdown;
|
||||
breakdownUnavailable: boolean;
|
||||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||
}) {
|
||||
const waitingClass = item.requestedAt
|
||||
? getWaitTimeTextClass(waitTimeColor(item.requestedAt))
|
||||
: undefined;
|
||||
return (
|
||||
<DetailSection title="Request">
|
||||
<DetailField label="Amount">
|
||||
<Text variant="body">
|
||||
{formatUpliftMoney(item.currentNTE ?? 0)} → {formatUpliftMoney(item.requestedNTE)}
|
||||
</Text>
|
||||
</DetailField>
|
||||
<DetailField label="Requested by">
|
||||
<Text variant="body">{item.requestedByVendorName || "—"}</Text>
|
||||
</DetailField>
|
||||
<DetailField label="Waiting">
|
||||
<Text variant="body" className={waitingClass}>
|
||||
{item.requestedAt ? timeSince(item.requestedAt) : "—"}
|
||||
</Text>
|
||||
</DetailField>
|
||||
<DetailField label="Notes">
|
||||
<Text variant="description">{item.vendorReason || "No justification provided."}</Text>
|
||||
</DetailField>
|
||||
<DetailField label="Attachments">
|
||||
<UpliftAttachments item={item} onOpenAttachment={onOpenAttachment} />
|
||||
</DetailField>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovedOnWoBreakdown({
|
||||
auto,
|
||||
admin,
|
||||
total,
|
||||
unavailable,
|
||||
}: {
|
||||
auto: number | null;
|
||||
admin: number | null;
|
||||
total: number | null;
|
||||
unavailable: boolean;
|
||||
}) {
|
||||
const rows: Array<{ label: string; value: number | null; strong: boolean }> = [
|
||||
{ label: "Auto-approved", value: auto, strong: false },
|
||||
{ label: "Admin-approved", value: admin, strong: false },
|
||||
{ label: "Total", value: total, strong: true },
|
||||
];
|
||||
return (
|
||||
<Box sx={{ display: "grid", gap: 0.5 }}>
|
||||
{rows.map((row) => (
|
||||
<BreakdownRow key={row.label} unavailable={unavailable} {...row} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function BreakdownRow({
|
||||
label,
|
||||
value,
|
||||
strong,
|
||||
unavailable,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | null;
|
||||
strong: boolean;
|
||||
unavailable: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<Text
|
||||
variant={strong ? "body" : "description"}
|
||||
tone={strong ? "default" : "muted"}
|
||||
className={strong ? "font-medium" : undefined}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
variant={strong ? "body" : "description"}
|
||||
className={strong ? "font-medium" : undefined}
|
||||
>
|
||||
{value != null ? formatUpliftMoney(value) : unavailable ? UNAVAILABLE_LABEL : "—"}
|
||||
<Box component="section" aria-label="Uplift request">
|
||||
<Text variant="heading" className="mb-2 block">
|
||||
Uplift request
|
||||
</Text>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.75 }}>
|
||||
<DetailField label="Requested By">
|
||||
{item.requestedByVendorName || "Unknown requester"}
|
||||
</DetailField>
|
||||
<DetailField label="Requested At">
|
||||
{formatUpliftDateTime(item.requestedAt) || "Not recorded"}
|
||||
</DetailField>
|
||||
</Box>
|
||||
<SubsectionTitle>Justification</SubsectionTitle>
|
||||
<JustificationBlock notes={item.vendorReason} />
|
||||
<SubsectionTitle>Approved on WO</SubsectionTitle>
|
||||
<ApprovedOnWoBreakdown breakdown={breakdown} unavailable={breakdownUnavailable} />
|
||||
<SubsectionTitle>Attachments</SubsectionTitle>
|
||||
<UpliftAttachments item={item} onOpenAttachment={onOpenAttachment} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -223,6 +250,19 @@ function PendingModalActions({
|
|||
const tooltip = canDecide ? "" : `Requires Tier ${item.requiredTier} role`;
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={tooltip}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled={!canDecide}
|
||||
onClick={() => onReject(item)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={tooltip}>
|
||||
<span>
|
||||
<Button
|
||||
|
|
@ -236,73 +276,61 @@ function PendingModalActions({
|
|||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={tooltip}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="error"
|
||||
disabled={!canDecide}
|
||||
onClick={() => onReject(item)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UpliftModalActions({
|
||||
function RevokeModalAction({
|
||||
item,
|
||||
closed,
|
||||
canRevoke,
|
||||
approvePending,
|
||||
revokePending,
|
||||
onApprove,
|
||||
onReject,
|
||||
onRevoke,
|
||||
}: {
|
||||
item: UpliftQueueItem;
|
||||
closed: boolean;
|
||||
canRevoke: boolean;
|
||||
approvePending: boolean;
|
||||
revokePending: boolean;
|
||||
onApprove: (row: UpliftQueueItem) => void;
|
||||
onReject: (row: UpliftQueueItem) => void;
|
||||
onRevoke: (row: UpliftQueueItem) => void;
|
||||
}) {
|
||||
const closed = item.workOrderClosed === true;
|
||||
const revokeTooltip = !canRevoke ? ADMIN_ONLY_REVOKE_TOOLTIP : closed ? CLOSED_WO_TOOLTIP : "";
|
||||
return (
|
||||
<>
|
||||
{item.status === "Pending" && (
|
||||
<PendingModalActions
|
||||
item={item}
|
||||
approvePending={approvePending}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
/>
|
||||
)}
|
||||
{item.status === "Approved" && (
|
||||
<Tooltip title={revokeTooltip}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled={!canRevoke || closed || item.workOrderId == null || revokePending}
|
||||
onClick={() => onRevoke(item)}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
<Tooltip title={revokeTooltip}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled={!canRevoke || closed || item.workOrderId == null || revokePending}
|
||||
onClick={() => onRevoke(item)}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpliftDetailModal({
|
||||
function useExposureBreakdown(item: UpliftQueueItem) {
|
||||
// Permission failures (e.g. 403 for account-scoped users) are handled quietly:
|
||||
// the breakdown falls back to "Unavailable" instead of toasting.
|
||||
const woUpliftsQuery = useWorkOrderUplifts(item.workOrderId ?? null, {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
const breakdown = useMemo<ExposureBreakdown>(() => {
|
||||
const woUplifts = woUpliftsQuery.data ?? [];
|
||||
const autoFallback = sumUpliftAmounts(woUplifts, "auto_approved");
|
||||
const adminFallback = sumUpliftAmounts(woUplifts, "approved");
|
||||
const hasWoData = woUplifts.length > 0;
|
||||
return {
|
||||
auto: item.approvedOnWoAuto ?? (hasWoData ? autoFallback : null),
|
||||
admin: item.approvedOnWoAdmin ?? (hasWoData ? adminFallback : null),
|
||||
total: item.approvedOnWoTotal ?? (hasWoData ? autoFallback + adminFallback : null),
|
||||
};
|
||||
}, [item.approvedOnWoAuto, item.approvedOnWoAdmin, item.approvedOnWoTotal, woUpliftsQuery.data]);
|
||||
return { breakdown, unavailable: woUpliftsQuery.isError };
|
||||
}
|
||||
|
||||
function UpliftDetailContent({
|
||||
item,
|
||||
canRevoke,
|
||||
approvePending,
|
||||
|
|
@ -312,98 +340,102 @@ export function UpliftDetailModal({
|
|||
onReject,
|
||||
onRevoke,
|
||||
onOpenAttachment,
|
||||
}: {
|
||||
item: UpliftQueueItem;
|
||||
canRevoke: boolean;
|
||||
approvePending: boolean;
|
||||
revokePending: boolean;
|
||||
} & ModalCallbacks) {
|
||||
const { breakdown, unavailable } = useExposureBreakdown(item);
|
||||
const isPending = item.status === "Pending";
|
||||
const isApproved = item.status === "Approved";
|
||||
const subtitle = [item.site, item.status].filter(Boolean).join(" · ");
|
||||
return (
|
||||
<>
|
||||
<DialogTitle sx={{ pr: 6 }}>
|
||||
<Text variant="title" as="div">
|
||||
Uplift — WO #{item.woNumber || item.dispatchNumber || "—"}
|
||||
</Text>
|
||||
<Text variant="description" tone="muted" when={subtitle.length > 0}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
<IconButton
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
size="small"
|
||||
sx={{ position: "absolute", top: 12, right: 12 }}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 1,
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Text variant="label" tone="muted">
|
||||
Amount
|
||||
</Text>
|
||||
<Text variant="heading" as="span" tone="warning">
|
||||
{formatUpliftMoney(item.requestedNTE)}
|
||||
</Text>
|
||||
</Box>
|
||||
<WorkOrderSection item={item} />
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
<UpliftRequestSection
|
||||
item={item}
|
||||
breakdown={breakdown}
|
||||
breakdownUnavailable={unavailable}
|
||||
onOpenAttachment={onOpenAttachment}
|
||||
/>
|
||||
</DialogContent>
|
||||
{(isPending || isApproved) && (
|
||||
<DialogActions sx={{ px: 2.5, py: 1.5 }}>
|
||||
{isPending && (
|
||||
<PendingModalActions
|
||||
item={item}
|
||||
approvePending={approvePending}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
/>
|
||||
)}
|
||||
{isApproved && (
|
||||
<RevokeModalAction
|
||||
item={item}
|
||||
canRevoke={canRevoke}
|
||||
revokePending={revokePending}
|
||||
onRevoke={onRevoke}
|
||||
/>
|
||||
)}
|
||||
</DialogActions>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpliftDetailModal({
|
||||
item,
|
||||
...rest
|
||||
}: {
|
||||
item: UpliftQueueItem | null;
|
||||
canRevoke: boolean;
|
||||
approvePending: boolean;
|
||||
revokePending: boolean;
|
||||
} & ModalCallbacks) {
|
||||
const open = item != null;
|
||||
// Permission failures (e.g. 403 for account-scoped users) are handled quietly
|
||||
// in this modal: dependent sections fall back to "Unavailable" instead of toasting.
|
||||
const quietMeta = { suppressErrorToast: true } as const;
|
||||
const boardDetail = useWorkOrderBoardDetail(
|
||||
item != null && item.workOrderId != null ? item.workOrderId : undefined,
|
||||
true,
|
||||
quietMeta,
|
||||
);
|
||||
const woUpliftsQuery = useWorkOrderUplifts(
|
||||
item != null && item.workOrderId != null ? item.workOrderId : null,
|
||||
quietMeta,
|
||||
);
|
||||
|
||||
const info = boardDetail.data?.info;
|
||||
const closed = useMemo(() => {
|
||||
if (item?.workOrderClosed === true) return true;
|
||||
return info ? isWorkOrderUpliftsReadOnly(info.status) : false;
|
||||
}, [item?.workOrderClosed, info]);
|
||||
|
||||
const breakdown = useMemo(() => {
|
||||
const woUplifts = woUpliftsQuery.data ?? [];
|
||||
const autoFallback = sumUpliftAmounts(woUplifts, "auto_approved");
|
||||
const adminFallback = sumUpliftAmounts(woUplifts, "approved");
|
||||
const hasWoData = woUplifts.length > 0;
|
||||
return {
|
||||
auto: item?.approvedOnWoAuto ?? (hasWoData ? autoFallback : null),
|
||||
admin: item?.approvedOnWoAdmin ?? (hasWoData ? adminFallback : null),
|
||||
total: item?.approvedOnWoTotal ?? (hasWoData ? autoFallback + adminFallback : null),
|
||||
};
|
||||
}, [
|
||||
item?.approvedOnWoAuto,
|
||||
item?.approvedOnWoAdmin,
|
||||
item?.approvedOnWoTotal,
|
||||
woUpliftsQuery.data,
|
||||
]);
|
||||
|
||||
if (!open) {
|
||||
if (item == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
aria-label="Uplift request details"
|
||||
>
|
||||
<DialogTitle>
|
||||
<Text variant="title" as="div">
|
||||
Uplift request
|
||||
</Text>
|
||||
<Text variant="description" tone="muted">
|
||||
{item.woNumber || item.dispatchNumber || "Work order"} · {item.status}
|
||||
</Text>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: "grid", gap: 3 }}>
|
||||
<WorkOrderSection item={item} info={info} unavailable={boardDetail.isError} />
|
||||
<RequestSection item={item} onOpenAttachment={onOpenAttachment} />
|
||||
<DetailSection title="Approved on work order">
|
||||
<ApprovedOnWoBreakdown
|
||||
auto={breakdown.auto}
|
||||
admin={breakdown.admin}
|
||||
total={breakdown.total}
|
||||
unavailable={woUpliftsQuery.isError}
|
||||
/>
|
||||
</DetailSection>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button size="small" variant="outlined" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<UpliftModalActions
|
||||
item={item}
|
||||
closed={closed}
|
||||
canRevoke={canRevoke}
|
||||
approvePending={approvePending}
|
||||
revokePending={revokePending}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
onRevoke={onRevoke}
|
||||
/>
|
||||
</DialogActions>
|
||||
<Dialog open onClose={rest.onClose} maxWidth="sm" fullWidth aria-label="Uplift request details">
|
||||
<UpliftDetailContent item={item} {...rest} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import {
|
|||
} from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
|
||||
import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs";
|
||||
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
|
||||
import { openUpliftEvidence } from "@/app/(protected)/uplifts/_components/open-uplift-evidence";
|
||||
import {
|
||||
openUpliftEvidence,
|
||||
openUpliftEvidenceInNewTab,
|
||||
} from "@/app/(protected)/uplifts/_components/open-uplift-evidence";
|
||||
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
|
||||
import { isAdminUser } from "@/lib/auth/user-utils";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
|
@ -181,7 +184,7 @@ export default function UpliftQueuePage() {
|
|||
onApprove={handleApprove}
|
||||
onReject={handleRejectRequest}
|
||||
onRevoke={handleRevokeRequest}
|
||||
onOpenAttachment={openUpliftEvidence}
|
||||
onOpenAttachment={openUpliftEvidenceInNewTab}
|
||||
/>
|
||||
)}
|
||||
<UpliftDecisionDialogs controller={controller} />
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type {
|
||||
WorkOrderTablePatch,
|
||||
|
|
@ -19,6 +20,8 @@ function isSlideOverInfoLocked(status: string | null | undefined): boolean {
|
|||
return isWorkOrderFullyLocked(status) || isWorkOrderCoreLocked(status);
|
||||
}
|
||||
|
||||
type StateSetter<T> = Dispatch<SetStateAction<T>>;
|
||||
|
||||
type UseSlideOverEditStateArgs = {
|
||||
row: WorkOrderTableRow | null;
|
||||
editMode: boolean;
|
||||
|
|
@ -27,26 +30,33 @@ type UseSlideOverEditStateArgs = {
|
|||
onClose: () => void;
|
||||
closeDisabled: boolean;
|
||||
setTab: (tab: SlideOverTab) => void;
|
||||
/** Tab to land on when a work order opens outside edit mode. */
|
||||
initialTab?: SlideOverTab;
|
||||
/** Changes for each explicit open request, including repeated requests for the same row/tab. */
|
||||
openRequestKey?: number;
|
||||
};
|
||||
|
||||
export function useSlideOverEditState({
|
||||
function useSlideOverInitialization({
|
||||
row,
|
||||
editMode,
|
||||
infoSource,
|
||||
onSave,
|
||||
onClose,
|
||||
closeDisabled,
|
||||
initialTab,
|
||||
setTab,
|
||||
}: UseSlideOverEditStateArgs) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
|
||||
/** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
|
||||
const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null);
|
||||
const [showUnsaved, setShowUnsaved] = useState(false);
|
||||
const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null);
|
||||
|
||||
// Reset draft/baseline only when the opened WO identity or editMode intent changes —
|
||||
// not on every optimistic parent-row field update during a failed/in-flight save.
|
||||
setDraft,
|
||||
setBaseline,
|
||||
setEditing,
|
||||
setShowUnsaved,
|
||||
setUnsavedIntent,
|
||||
}: {
|
||||
row: WorkOrderTableRow | null;
|
||||
editMode: boolean;
|
||||
initialTab: SlideOverTab;
|
||||
setTab: (tab: SlideOverTab) => void;
|
||||
setDraft: StateSetter<WorkOrderTableRow | null>;
|
||||
setBaseline: StateSetter<WorkOrderTableRow | null>;
|
||||
setEditing: StateSetter<boolean>;
|
||||
setShowUnsaved: StateSetter<boolean>;
|
||||
setUnsavedIntent: StateSetter<"close" | "cancel-edit" | null>;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!row) {
|
||||
setDraft(null);
|
||||
|
|
@ -59,12 +69,50 @@ export function useSlideOverEditState({
|
|||
setDraft(row);
|
||||
setBaseline(row);
|
||||
setEditing(editMode && !isSlideOverInfoLocked(row.status));
|
||||
setTab("info");
|
||||
setTab(editMode ? "info" : initialTab);
|
||||
setShowUnsaved(false);
|
||||
setUnsavedIntent(null);
|
||||
// Intentionally identity-only: depending on `row` would absorb optimistic patches.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
|
||||
}, [row?.id, editMode]);
|
||||
}
|
||||
|
||||
export function useSlideOverEditState({
|
||||
row,
|
||||
editMode,
|
||||
infoSource,
|
||||
onSave,
|
||||
onClose,
|
||||
closeDisabled,
|
||||
setTab,
|
||||
initialTab = "info",
|
||||
openRequestKey = 0,
|
||||
}: UseSlideOverEditStateArgs) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
|
||||
/** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
|
||||
const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null);
|
||||
const [showUnsaved, setShowUnsaved] = useState(false);
|
||||
const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null);
|
||||
|
||||
// Reset draft/baseline only when the opened WO identity or editMode intent changes —
|
||||
// not on every optimistic parent-row field update during a failed/in-flight save.
|
||||
useSlideOverInitialization({
|
||||
row,
|
||||
editMode,
|
||||
initialTab,
|
||||
setTab,
|
||||
setDraft,
|
||||
setBaseline,
|
||||
setEditing,
|
||||
setShowUnsaved,
|
||||
setUnsavedIntent,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (row?.id == null || editMode) return;
|
||||
setTab(initialTab);
|
||||
}, [editMode, initialTab, openRequestKey, row?.id, setTab]);
|
||||
|
||||
const lockStatus = infoSource?.status ?? row?.status;
|
||||
const isInfoLocked = isSlideOverInfoLocked(lockStatus);
|
||||
|
|
@ -95,8 +143,7 @@ export function useSlideOverEditState({
|
|||
});
|
||||
}, [infoSource, editing, baseline]);
|
||||
|
||||
const activeDraft = draft ?? infoSource;
|
||||
const dirtyBaseline = baseline ?? infoSource;
|
||||
const [activeDraft, dirtyBaseline] = [draft ?? infoSource, baseline ?? infoSource];
|
||||
const isDirty = useMemo(() => {
|
||||
if (!dirtyBaseline || !activeDraft || !editing) return false;
|
||||
return isSlideOverDraftDirty(activeDraft, dirtyBaseline);
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ type UseWorkOrderSlideOverArgs = {
|
|||
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
|
||||
onClose: () => void;
|
||||
saving?: boolean;
|
||||
initialTab?: SlideOverTab;
|
||||
openRequestKey?: number;
|
||||
};
|
||||
|
||||
type CompletionUploadMutate = (input: { file: File; workOrderVersion: string }) => void;
|
||||
|
|
@ -90,6 +92,8 @@ export function useWorkOrderSlideOver({
|
|||
onSave,
|
||||
onClose,
|
||||
saving,
|
||||
initialTab,
|
||||
openRequestKey,
|
||||
}: UseWorkOrderSlideOverArgs) {
|
||||
const { user } = useAuthContext();
|
||||
const workOrderId = row?.id;
|
||||
|
|
@ -138,6 +142,8 @@ export function useWorkOrderSlideOver({
|
|||
onClose,
|
||||
closeDisabled,
|
||||
setTab,
|
||||
initialTab,
|
||||
openRequestKey,
|
||||
});
|
||||
|
||||
const uploadCompletionPdf = (file: File) => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import type {
|
|||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { useWorkOrderSlideOver } from "./use-work-order-slide-over";
|
||||
import { useWorkOrderSlideOver, type SlideOverTab } from "./use-work-order-slide-over";
|
||||
import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav";
|
||||
import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
|
||||
|
||||
|
|
@ -35,6 +35,8 @@ type WorkOrderSlideOverProps = {
|
|||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
onClose: () => void;
|
||||
saving?: boolean;
|
||||
initialTab?: SlideOverTab;
|
||||
openRequestKey?: number;
|
||||
};
|
||||
|
||||
export function WorkOrderSlideOver({
|
||||
|
|
@ -52,8 +54,18 @@ export function WorkOrderSlideOver({
|
|||
onDuplicateFound,
|
||||
onClose,
|
||||
saving,
|
||||
initialTab,
|
||||
openRequestKey,
|
||||
}: WorkOrderSlideOverProps) {
|
||||
const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving });
|
||||
const state = useWorkOrderSlideOver({
|
||||
row,
|
||||
editMode,
|
||||
onSave,
|
||||
onClose,
|
||||
saving,
|
||||
initialTab,
|
||||
openRequestKey,
|
||||
});
|
||||
const { infoSource, activeDraft } = state;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
|
|||
<WorkOrderSlideOver
|
||||
row={page.activeSlideOverRow}
|
||||
editMode={page.slideOverEdit}
|
||||
initialTab={page.slideOverTab}
|
||||
openRequestKey={page.slideOverOpenRequestKey}
|
||||
users={users}
|
||||
sites={locations}
|
||||
vendors={vendors}
|
||||
|
|
|
|||
|
|
@ -18,17 +18,19 @@ export function useDashboardDrilldownFilters(
|
|||
}, [onApply]);
|
||||
|
||||
useEffect(() => {
|
||||
const drilldown = parseDashboardDrilldownParams(searchParams);
|
||||
if (!drilldown) {
|
||||
// The applied params were cleared; a later drilldown link on this mounted page applies too.
|
||||
appliedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (appliedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const drilldown = parseDashboardDrilldownParams(searchParams);
|
||||
if (!drilldown) {
|
||||
return;
|
||||
}
|
||||
|
||||
appliedRef.current = true;
|
||||
setFromDashboard(true);
|
||||
setFromDashboard(searchParams.get("fromDashboard") === "1");
|
||||
onApplyRef.current(drilldown);
|
||||
setSearchParams({}, { replace: true });
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import type { SlideOverTab } from "@/app/(protected)/workorders/_components/detail/use-work-order-slide-over";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
parseWorkOrderDeepLink,
|
||||
withoutWorkOrderDeepLink,
|
||||
} from "@/domain/work-orders/utils/work-order-deep-link";
|
||||
|
||||
type OpenSlideOver = (row: WorkOrderTableRow, edit: boolean, tab: SlideOverTab) => void;
|
||||
|
||||
/**
|
||||
* Opens `?wo=<id>&tab=<tab>` in the slide-over once that work order's detail loads, whatever week
|
||||
* the board is showing, then removes the params so closing the slide-over does not reopen it.
|
||||
*/
|
||||
export function useWorkOrderDeepLink(onOpen: OpenSlideOver): void {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const link = parseWorkOrderDeepLink(searchParams);
|
||||
const workOrderId = link?.workOrderId;
|
||||
const tab = link?.tab ?? "info";
|
||||
const { data, error } = useWorkOrderBoardDetail(workOrderId, workOrderId != null);
|
||||
const onOpenRef = useRef(onOpen);
|
||||
|
||||
useEffect(() => {
|
||||
onOpenRef.current = onOpen;
|
||||
}, [onOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workOrderId == null) {
|
||||
return;
|
||||
}
|
||||
const loaded = data != null && String(data.info.id) === workOrderId;
|
||||
if (!loaded && error == null) {
|
||||
return;
|
||||
}
|
||||
if (loaded) {
|
||||
onOpenRef.current(data.info, false, tab);
|
||||
}
|
||||
setSearchParams(withoutWorkOrderDeepLink(searchParams), { replace: true });
|
||||
}, [workOrderId, tab, data, error, searchParams, setSearchParams]);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { SlideOverTab } from "@/app/(protected)/workorders/_components/detail/use-work-order-slide-over";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
useWorkOrderLocations,
|
||||
|
|
@ -17,10 +18,45 @@ import { guardDialogClose } from "@/lib/dialog-guard";
|
|||
import { useWorkOrdersListFilters } from "./use-work-orders-list-filters";
|
||||
import { useWorkOrdersTableData } from "./use-work-orders-table-data";
|
||||
|
||||
export function useWorkOrdersListPage() {
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
function useSlideOverOpenState() {
|
||||
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
|
||||
const [slideOverEdit, setSlideOverEdit] = useState(false);
|
||||
const [slideOverTab, setSlideOverTab] = useState<SlideOverTab>("info");
|
||||
const [slideOverOpenRequestKey, setSlideOverOpenRequestKey] = useState(0);
|
||||
|
||||
const handleOpenSlideOver = (
|
||||
row: WorkOrderTableRow,
|
||||
edit = false,
|
||||
tab: SlideOverTab = "info",
|
||||
) => {
|
||||
setSlideOverRow(row);
|
||||
setSlideOverEdit(edit);
|
||||
setSlideOverTab(tab);
|
||||
setSlideOverOpenRequestKey((key) => key + 1);
|
||||
};
|
||||
|
||||
return {
|
||||
slideOverRow,
|
||||
setSlideOverRow,
|
||||
slideOverEdit,
|
||||
setSlideOverEdit,
|
||||
slideOverTab,
|
||||
slideOverOpenRequestKey,
|
||||
handleOpenSlideOver,
|
||||
};
|
||||
}
|
||||
|
||||
export function useWorkOrdersListPage() {
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const {
|
||||
slideOverRow,
|
||||
setSlideOverRow,
|
||||
slideOverEdit,
|
||||
setSlideOverEdit,
|
||||
slideOverTab,
|
||||
slideOverOpenRequestKey,
|
||||
handleOpenSlideOver,
|
||||
} = useSlideOverOpenState();
|
||||
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
|
||||
const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null);
|
||||
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
|
||||
|
|
@ -74,11 +110,6 @@ export function useWorkOrdersListPage() {
|
|||
return tableData.rows.find((row) => row.id === upliftsRow.id) ?? upliftsRow;
|
||||
}, [upliftsRow, tableData.rows]);
|
||||
|
||||
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
|
||||
setSlideOverRow(row);
|
||||
setSlideOverEdit(edit);
|
||||
};
|
||||
|
||||
const handleCloseSlideOver = () => {
|
||||
setSlideOverRow(null);
|
||||
setSlideOverEdit(false);
|
||||
|
|
@ -130,6 +161,8 @@ export function useWorkOrdersListPage() {
|
|||
wizardOpen,
|
||||
setWizardOpen,
|
||||
slideOverEdit,
|
||||
slideOverTab,
|
||||
slideOverOpenRequestKey,
|
||||
confirmCancel,
|
||||
setConfirmCancel,
|
||||
confirmComplete,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_componen
|
|||
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
|
||||
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
|
||||
import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters";
|
||||
import { useWorkOrderDeepLink } from "@/app/(protected)/workorders/_hooks/use-work-order-deep-link";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
|
@ -27,6 +28,7 @@ export default function WorkOrdersListPage() {
|
|||
const page = useWorkOrdersListPage();
|
||||
const { filters, tableData } = page;
|
||||
const fromDashboard = useDashboardDrilldownFilters(filters.applyAdvancedFilters);
|
||||
useWorkOrderDeepLink(page.handleOpenSlideOver);
|
||||
|
||||
return (
|
||||
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
||||
|
|
|
|||
189
src/components/layout/app-topbar-user-menu.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined";
|
||||
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
|
||||
import {
|
||||
Box,
|
||||
Divider,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
import { getPrimaryUserRole } from "@/lib/auth/user-utils";
|
||||
|
||||
type UserMenuHeaderProps = {
|
||||
initials: string;
|
||||
fullname: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
function UserMenuHeader({ initials, fullname, email }: UserMenuHeaderProps) {
|
||||
return (
|
||||
<Box className="flex items-center gap-3 px-4 py-3">
|
||||
<Box
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, var(--accent), var(--primary))",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 600,
|
||||
color: "#ffffff",
|
||||
border: "2px solid var(--color-header-avatar-ring)",
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{fullname}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{email}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppTopbarUserMenu() {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuthContext();
|
||||
|
||||
const [userAnchor, setUserAnchor] = useState<null | HTMLElement>(null);
|
||||
|
||||
const handleLogout = () => {
|
||||
setUserAnchor(null);
|
||||
logout();
|
||||
};
|
||||
|
||||
const userInitials = user?.fullname
|
||||
? user.fullname
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase()
|
||||
: "U";
|
||||
|
||||
const userRole = getPrimaryUserRole(user?.userRoles);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={Boolean(userAnchor)}
|
||||
onClick={(event) => setUserAnchor(event.currentTarget)}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexShrink: 0,
|
||||
gap: "10px",
|
||||
py: "5px",
|
||||
pl: "6px",
|
||||
pr: "10px",
|
||||
borderRadius: "9999px",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--color-header-text)",
|
||||
transition: "background-color var(--duration-fast, 150ms) var(--ease-default, ease)",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--color-header-chip-hover)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
sx={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, var(--accent), var(--primary))",
|
||||
boxShadow: "0 0 0 2px var(--color-header-avatar-ring)",
|
||||
fontFamily: "var(--font-display)",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "#ffffff",
|
||||
}}
|
||||
>
|
||||
{userInitials}
|
||||
</Box>
|
||||
<Box className="text-left leading-tight">
|
||||
<Typography
|
||||
component="p"
|
||||
sx={{
|
||||
fontFamily: "var(--font-sans)",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#ffffff",
|
||||
lineHeight: 1.25,
|
||||
}}
|
||||
>
|
||||
{user?.fullname || "User"}
|
||||
</Typography>
|
||||
<Typography
|
||||
component="p"
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-header-text-muted)",
|
||||
lineHeight: 1.25,
|
||||
}}
|
||||
>
|
||||
{userRole}
|
||||
</Typography>
|
||||
</Box>
|
||||
<ChevronDown
|
||||
size={15}
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--color-header-text-muted)" }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Menu
|
||||
anchorEl={userAnchor}
|
||||
open={Boolean(userAnchor)}
|
||||
onClose={() => setUserAnchor(null)}
|
||||
transformOrigin={{ horizontal: "right", vertical: "top" }}
|
||||
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: { width: 260, mt: 1, boxShadow: "var(--shadow-lg)" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<UserMenuHeader
|
||||
initials={userInitials}
|
||||
fullname={user?.fullname || "User"}
|
||||
email={user?.email || ""}
|
||||
/>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setUserAnchor(null);
|
||||
navigate("/settings/dropdowns");
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<SettingsOutlinedIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Settings</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleLogout} sx={{ color: "error.main" }}>
|
||||
<ListItemIcon>
|
||||
<LogoutOutlinedIcon fontSize="small" color="error" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Logout</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,49 +1,14 @@
|
|||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined";
|
||||
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
|
||||
import {
|
||||
AppBar,
|
||||
Box,
|
||||
Divider,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Toolbar,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { ChevronDown, Menu as MenuIcon } from "lucide-react";
|
||||
import { AppBar, Box, Toolbar } from "@mui/material";
|
||||
import { Menu as MenuIcon } from "lucide-react";
|
||||
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
import { getPrimaryUserRole } from "@/lib/auth/user-utils";
|
||||
import { AppTopbarUserMenu } from "@/components/layout/app-topbar-user-menu";
|
||||
import { NotificationBell } from "@/components/notifications/notification-bell";
|
||||
|
||||
type AppTopbarProps = {
|
||||
onToggleNav: () => void;
|
||||
};
|
||||
|
||||
export function AppTopbar({ onToggleNav }: AppTopbarProps) {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuthContext();
|
||||
|
||||
const [userAnchor, setUserAnchor] = useState<null | HTMLElement>(null);
|
||||
|
||||
const handleLogout = () => {
|
||||
setUserAnchor(null);
|
||||
logout();
|
||||
};
|
||||
|
||||
const userInitials = user?.fullname
|
||||
? user.fullname
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase()
|
||||
: "U";
|
||||
|
||||
const userRole = getPrimaryUserRole(user?.userRoles);
|
||||
|
||||
return (
|
||||
<AppBar
|
||||
position="sticky"
|
||||
|
|
@ -97,135 +62,10 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
|
|||
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={Boolean(userAnchor)}
|
||||
onClick={(event) => setUserAnchor(event.currentTarget)}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexShrink: 0,
|
||||
gap: "10px",
|
||||
py: "5px",
|
||||
pl: "6px",
|
||||
pr: "10px",
|
||||
borderRadius: "9999px",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--color-header-text)",
|
||||
transition: "background-color var(--duration-fast, 150ms) var(--ease-default, ease)",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--color-header-chip-hover)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
sx={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, var(--accent), var(--primary))",
|
||||
boxShadow: "0 0 0 2px var(--color-header-avatar-ring)",
|
||||
fontFamily: "var(--font-display)",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "#ffffff",
|
||||
}}
|
||||
>
|
||||
{userInitials}
|
||||
</Box>
|
||||
<Box className="text-left leading-tight">
|
||||
<Typography
|
||||
component="p"
|
||||
sx={{
|
||||
fontFamily: "var(--font-sans)",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#ffffff",
|
||||
lineHeight: 1.25,
|
||||
}}
|
||||
>
|
||||
{user?.fullname || "User"}
|
||||
</Typography>
|
||||
<Typography
|
||||
component="p"
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-header-text-muted)",
|
||||
lineHeight: 1.25,
|
||||
}}
|
||||
>
|
||||
{userRole}
|
||||
</Typography>
|
||||
</Box>
|
||||
<ChevronDown
|
||||
size={15}
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--color-header-text-muted)" }}
|
||||
/>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
<NotificationBell />
|
||||
|
||||
<Menu
|
||||
anchorEl={userAnchor}
|
||||
open={Boolean(userAnchor)}
|
||||
onClose={() => setUserAnchor(null)}
|
||||
transformOrigin={{ horizontal: "right", vertical: "top" }}
|
||||
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: { width: 260, mt: 1, boxShadow: "var(--shadow-lg)" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box className="flex items-center gap-3 px-4 py-3">
|
||||
<Box
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, var(--accent), var(--primary))",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 600,
|
||||
color: "#ffffff",
|
||||
border: "2px solid var(--color-header-avatar-ring)",
|
||||
}}
|
||||
>
|
||||
{userInitials}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{user?.fullname || "User"}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{user?.email || ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setUserAnchor(null);
|
||||
navigate("/settings/dropdowns");
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<SettingsOutlinedIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Settings</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleLogout} sx={{ color: "error.main" }}>
|
||||
<ListItemIcon>
|
||||
<LogoutOutlinedIcon fontSize="small" color="error" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Logout</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<AppTopbarUserMenu />
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
98
src/components/notifications/notification-bell.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { useState } from "react";
|
||||
import { Box, Drawer } from "@mui/material";
|
||||
import { Bell, X } from "lucide-react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { NotificationFeedActions } from "./notification-feed-actions";
|
||||
import { NotificationFeedList } from "./notification-feed-list";
|
||||
import { useNotificationCenter } from "./use-notification-center";
|
||||
|
||||
export function NotificationBell() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const center = useNotificationCenter(() => setOpen(false));
|
||||
const unreadCount = center.unreadIds.size;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
title="Notifications"
|
||||
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
|
||||
sx={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: "6px",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
overflow: "visible",
|
||||
backgroundColor: "var(--color-header-surface)",
|
||||
color: "var(--color-header-icon)",
|
||||
"&:hover": { backgroundColor: "var(--color-header-surface-hover)" },
|
||||
}}
|
||||
>
|
||||
<Bell size={18} aria-hidden />
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 2,
|
||||
right: 2,
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
padding: "0 4px",
|
||||
borderRadius: 9999,
|
||||
background: "var(--destructive)",
|
||||
color: "var(--destructive-foreground)",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Box>
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
slotProps={{ paper: { sx: { width: 420, background: "var(--color-bg-surface)" } } }}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2"
|
||||
style={{ padding: "16px 20px", borderBottom: "1px solid var(--color-border)" }}
|
||||
>
|
||||
<Text
|
||||
as="h2"
|
||||
variant="title"
|
||||
sx={{ flex: 1, fontSize: 18, fontWeight: 700, color: "var(--color-text-primary)" }}
|
||||
>
|
||||
Notifications
|
||||
</Text>
|
||||
<NotificationFeedActions center={center} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
title="Close"
|
||||
aria-label="Close notifications"
|
||||
className="shrink-0 cursor-pointer transition-opacity hover:opacity-70"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<X size={16} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto" style={{ padding: "8px 20px 20px" }}>
|
||||
<NotificationFeedList center={center} />
|
||||
</div>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
33
src/components/notifications/notification-feed-actions.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { NotificationCenter } from "./use-notification-center";
|
||||
|
||||
type NotificationFeedActionsProps = {
|
||||
center: NotificationCenter;
|
||||
};
|
||||
|
||||
export function NotificationFeedActions({ center }: NotificationFeedActionsProps) {
|
||||
const hasUnread = center.unreadIds.size > 0;
|
||||
const hasItems = center.sections.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={center.markAllRead}
|
||||
disabled={!hasUnread}
|
||||
className="cursor-pointer transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ fontSize: 12, fontWeight: 500, color: "var(--color-primary)" }}
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={center.dismissAll}
|
||||
disabled={!hasItems}
|
||||
className="cursor-pointer transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ fontSize: 12, fontWeight: 500, color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
89
src/components/notifications/notification-feed-list.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { CircularProgress } from "@mui/material";
|
||||
import { Inbox } from "lucide-react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { NotificationCenter } from "./use-notification-center";
|
||||
import { NotificationRow } from "./notification-row";
|
||||
|
||||
type NotificationFeedListProps = {
|
||||
center: NotificationCenter;
|
||||
};
|
||||
|
||||
export function NotificationFeedList({ center }: NotificationFeedListProps) {
|
||||
if (center.isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-16">
|
||||
<CircularProgress size={24} aria-label="Loading notifications" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (center.error != null && !center.hasData) {
|
||||
return (
|
||||
<Text variant="error" className="py-6">
|
||||
Notifications could not be loaded. They will retry automatically.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (center.sections.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16">
|
||||
<Inbox size={24} aria-hidden style={{ color: "var(--color-text-muted)" }} />
|
||||
<Text variant="description" sx={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
Nothing needs your attention
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{center.sections.map((section) => (
|
||||
<section
|
||||
key={section.reason}
|
||||
aria-label={`${section.label}, ${section.count}`}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<div className="flex items-center gap-2" style={{ marginBottom: 4 }}>
|
||||
<Text
|
||||
as="h3"
|
||||
variant="label"
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.06em",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{section.label}
|
||||
</Text>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color: "var(--color-text-secondary)",
|
||||
background: "var(--color-bg-muted)",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
padding: "1px 6px",
|
||||
}}
|
||||
>
|
||||
{section.count}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="m-0 list-none p-0">
|
||||
{section.items.map((item) => (
|
||||
<NotificationRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
unread={center.unreadIds.has(item.id)}
|
||||
onOpen={center.open}
|
||||
onDismiss={center.dismiss}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
src/components/notifications/notification-row.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
Clock,
|
||||
Inbox,
|
||||
MessageSquare,
|
||||
ShieldAlert,
|
||||
Wrench,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
|
||||
import { workOrderViewUrl } from "@/domain/notifications/utils/notification-target-url";
|
||||
import { isDismissable } from "@/domain/notifications/utils/visible-notification-sections";
|
||||
|
||||
const REASON_ICONS: Record<string, LucideIcon> = {
|
||||
"severity-critical": AlertTriangle,
|
||||
sla: AlertTriangle,
|
||||
mentions: MessageSquare,
|
||||
unassigned: Inbox,
|
||||
"due-soon": Clock,
|
||||
"no-vendor": Wrench,
|
||||
"aveta-missing": ShieldAlert,
|
||||
activity: MessageSquare,
|
||||
"vendor-conflict": Wrench,
|
||||
};
|
||||
|
||||
type NotificationRowProps = {
|
||||
item: NotificationItem;
|
||||
unread: boolean;
|
||||
onOpen: (item: NotificationItem, url?: string) => void;
|
||||
onDismiss: (item: NotificationItem) => void;
|
||||
};
|
||||
|
||||
/** A vendor conflict spans several work orders, so each one stays reachable from the row. */
|
||||
function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "onOpen">) {
|
||||
if (item.reason !== "vendor-conflict" || item.workOrders.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex flex-wrap gap-2" style={{ paddingLeft: 31, marginTop: 2 }}>
|
||||
{item.workOrders.map((workOrder) => {
|
||||
const url = workOrderViewUrl(workOrder.id);
|
||||
return (
|
||||
<Link
|
||||
key={workOrder.id}
|
||||
to={url}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onOpen(item, url);
|
||||
}}
|
||||
className="text-xs underline-offset-2 hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
{workOrder.number != null ? `WO #${workOrder.number}` : `WO ${workOrder.id}`}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationRow({ item, unread, onOpen, onDismiss }: NotificationRowProps) {
|
||||
const Icon = REASON_ICONS[item.reason] ?? Bell;
|
||||
|
||||
return (
|
||||
<li
|
||||
className="group flex items-start gap-2.5 transition-colors hover:bg-[var(--color-bg-muted)]"
|
||||
style={{
|
||||
padding: "10px 8px",
|
||||
marginInline: -8,
|
||||
borderRadius: "var(--radius-md)",
|
||||
borderBottom: "1px solid var(--color-bg-muted)",
|
||||
}}
|
||||
data-unread={unread}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(item)}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-start gap-2.5 text-left"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="shrink-0 rounded-full"
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
marginTop: 7,
|
||||
background: unread ? "var(--color-primary)" : "transparent",
|
||||
}}
|
||||
/>
|
||||
<Icon
|
||||
size={15}
|
||||
aria-hidden
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--color-text-muted)", marginTop: 2 }}
|
||||
/>
|
||||
<Text
|
||||
as="span"
|
||||
variant="description"
|
||||
sx={{ fontSize: 13, lineHeight: 1.45, fontWeight: unread ? 500 : 400, margin: 0 }}
|
||||
>
|
||||
{unread && <span className="sr-only">Unread: </span>}
|
||||
{item.title}
|
||||
</Text>
|
||||
</button>
|
||||
<WorkOrderLinks item={item} onOpen={onOpen} />
|
||||
</span>
|
||||
{isDismissable(item) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDismiss(item)}
|
||||
title="Dismiss"
|
||||
aria-label="Dismiss notification"
|
||||
className="shrink-0 cursor-pointer opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||
style={{ color: "var(--color-text-muted)", marginTop: 2 }}
|
||||
>
|
||||
<X size={13} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
47
src/components/notifications/use-notification-center.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
|
||||
import { useNotificationSession } from "@/domain/notifications/session/notification-session-context";
|
||||
import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
|
||||
import { notificationTargetUrl } from "@/domain/notifications/utils/notification-target-url";
|
||||
import {
|
||||
isDismissable,
|
||||
visibleNotificationSections,
|
||||
} from "@/domain/notifications/utils/visible-notification-sections";
|
||||
|
||||
export function useNotificationCenter(onNavigate?: () => void) {
|
||||
const navigate = useNavigate();
|
||||
const feed = useNotificationFeed();
|
||||
const { dismissedIds, readIds, dismiss, markRead } = useNotificationSession();
|
||||
|
||||
const sections = useMemo(
|
||||
() => visibleNotificationSections(feed.data?.sections ?? [], dismissedIds),
|
||||
[feed.data, dismissedIds],
|
||||
);
|
||||
const items = useMemo(() => sections.flatMap((section) => section.items), [sections]);
|
||||
const unreadIds = useMemo(
|
||||
() => new Set(items.filter((item) => !readIds.has(item.id)).map((item) => item.id)),
|
||||
[items, readIds],
|
||||
);
|
||||
|
||||
const open = (item: NotificationItem, url = notificationTargetUrl(item)) => {
|
||||
markRead([item.id]);
|
||||
if (url == null) return;
|
||||
onNavigate?.();
|
||||
navigate(url);
|
||||
};
|
||||
|
||||
return {
|
||||
sections,
|
||||
unreadIds,
|
||||
isLoading: feed.isLoading,
|
||||
hasData: feed.data != null,
|
||||
error: feed.error,
|
||||
open,
|
||||
dismiss: (item: NotificationItem) => dismiss([item.id]),
|
||||
dismissAll: () => dismiss(items.filter(isDismissable).map((item) => item.id)),
|
||||
markAllRead: () => markRead(items.map((item) => item.id)),
|
||||
};
|
||||
}
|
||||
|
||||
export type NotificationCenter = ReturnType<typeof useNotificationCenter>;
|
||||
|
|
@ -14,8 +14,15 @@ export const OPEN_WIZARD_STATUSES: readonly string[] = ALL_WIZARD_STATUSES.filte
|
|||
(status) => status !== "Completed",
|
||||
);
|
||||
|
||||
export function workOrderDrilldownUrl(search: URLSearchParams): string {
|
||||
const query = search.toString();
|
||||
export function workOrderDrilldownUrl(
|
||||
search: URLSearchParams,
|
||||
source: "dashboard" | "notification" = "dashboard",
|
||||
): string {
|
||||
const params = new URLSearchParams(search);
|
||||
if (source === "dashboard") {
|
||||
params.set("fromDashboard", "1");
|
||||
}
|
||||
const query = params.toString();
|
||||
return query ? `${WORK_ORDERS_ROUTE}?${query}` : WORK_ORDERS_ROUTE;
|
||||
}
|
||||
|
||||
|
|
|
|||
10
src/domain/notifications/api/notifications-api.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { apiGet } from "@/api/api";
|
||||
import {
|
||||
notificationFeedSchema,
|
||||
type NotificationFeed,
|
||||
} from "@/domain/notifications/types/notification-feed";
|
||||
|
||||
export const notificationsApi = {
|
||||
feed: async (): Promise<NotificationFeed> =>
|
||||
notificationFeedSchema.parse(await apiGet<unknown>("notifications")),
|
||||
};
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { createContext, useContext } from "react";
|
||||
|
||||
export type NotificationSession = {
|
||||
dismissedIds: ReadonlySet<string>;
|
||||
readIds: ReadonlySet<string>;
|
||||
dismiss: (ids: string[]) => void;
|
||||
markRead: (ids: string[]) => void;
|
||||
};
|
||||
|
||||
export const NotificationSessionContext = createContext<NotificationSession | null>(null);
|
||||
|
||||
export function useNotificationSession(): NotificationSession {
|
||||
const session = useContext(NotificationSessionContext);
|
||||
if (!session) {
|
||||
throw new Error("useNotificationSession must be used within a NotificationSessionProvider");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { NotificationSessionContext } from "./notification-session-context";
|
||||
|
||||
function addAll(current: ReadonlySet<string>, ids: string[]): ReadonlySet<string> {
|
||||
if (ids.every((id) => current.has(id))) {
|
||||
return current;
|
||||
}
|
||||
const next = new Set(current);
|
||||
ids.forEach((id) => next.add(id));
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismissed and read notification ids for this app session only. Nothing is persisted, so a
|
||||
* reload starts over and a condition that still holds shows up again.
|
||||
*/
|
||||
export function NotificationSessionProvider({ children }: { children: ReactNode }) {
|
||||
const [dismissedIds, setDismissedIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [readIds, setReadIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||
|
||||
const dismiss = useCallback((ids: string[]) => {
|
||||
setDismissedIds((current) => addAll(current, ids));
|
||||
}, []);
|
||||
const markRead = useCallback((ids: string[]) => {
|
||||
setReadIds((current) => addAll(current, ids));
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ dismissedIds, readIds, dismiss, markRead }),
|
||||
[dismissedIds, readIds, dismiss, markRead],
|
||||
);
|
||||
|
||||
return (
|
||||
<NotificationSessionContext.Provider value={value}>
|
||||
{children}
|
||||
</NotificationSessionContext.Provider>
|
||||
);
|
||||
}
|
||||
62
src/domain/notifications/types/notification-feed.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/** Fixed reason order; it breaks ties between sections of equal severity. */
|
||||
export const NOTIFICATION_REASON_ORDER = [
|
||||
"severity-critical",
|
||||
"sla",
|
||||
"mentions",
|
||||
"unassigned",
|
||||
"due-soon",
|
||||
"no-vendor",
|
||||
"aveta-missing",
|
||||
"activity",
|
||||
"vendor-conflict",
|
||||
] as const;
|
||||
|
||||
export const NOTIFICATION_SEVERITY_RANK = {
|
||||
Critical: 3,
|
||||
High: 2,
|
||||
Medium: 1,
|
||||
Low: 0,
|
||||
} as const;
|
||||
|
||||
const severitySchema = z.enum(["Critical", "High", "Medium", "Low"]);
|
||||
|
||||
const targetSchema = z.object({
|
||||
kind: z.enum(["workOrder", "queue"]),
|
||||
workOrderId: z.number().nullish(),
|
||||
tab: z.string().nullish(),
|
||||
queue: z.string().nullish(),
|
||||
});
|
||||
|
||||
const itemSchema = z.object({
|
||||
id: z.string(),
|
||||
reason: z.string(),
|
||||
severity: severitySchema,
|
||||
// "acknowledge" rows (a missed response deadline) never take the normal dismiss.
|
||||
rowType: z.enum(["dismissable", "acknowledge"]),
|
||||
title: z.string(),
|
||||
count: z.number(),
|
||||
triggeredAt: z.string(),
|
||||
target: targetSchema,
|
||||
workOrders: z.array(z.object({ id: z.number(), number: z.string().nullish() })),
|
||||
});
|
||||
|
||||
const sectionSchema = z.object({
|
||||
reason: z.string(),
|
||||
label: z.string(),
|
||||
severity: severitySchema,
|
||||
count: z.number(),
|
||||
items: z.array(itemSchema),
|
||||
});
|
||||
|
||||
export const notificationFeedSchema = z.object({
|
||||
generatedAt: z.string(),
|
||||
sections: z.array(sectionSchema),
|
||||
});
|
||||
|
||||
export type NotificationSeverity = z.infer<typeof severitySchema>;
|
||||
export type NotificationTarget = z.infer<typeof targetSchema>;
|
||||
export type NotificationItem = z.infer<typeof itemSchema>;
|
||||
export type NotificationSection = z.infer<typeof sectionSchema>;
|
||||
export type NotificationFeed = z.infer<typeof notificationFeedSchema>;
|
||||
16
src/domain/notifications/use-cases/use-notification-feed.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { notificationsApi } from "@/domain/notifications/api/notifications-api";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
/** Live conditions are recomputed server-side on every read, so polling keeps counts current. */
|
||||
export const NOTIFICATION_FEED_REFRESH_MS = 30_000;
|
||||
|
||||
export function useNotificationFeed() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.notifications.feed(),
|
||||
queryFn: notificationsApi.feed,
|
||||
refetchInterval: NOTIFICATION_FEED_REFRESH_MS,
|
||||
// The feed shows its own error state; a toast on every 30s poll would repeat the same failure.
|
||||
meta: { suppressErrorToast: true },
|
||||
});
|
||||
}
|
||||
46
src/domain/notifications/utils/notification-target-url.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import {
|
||||
OPEN_WIZARD_STATUSES,
|
||||
workOrderDrilldownUrl,
|
||||
} from "@/domain/dashboard/utils/work-order-drilldown-links";
|
||||
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
|
||||
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
|
||||
import {
|
||||
UNASSIGNED_QUEUE_DATE_FROM,
|
||||
UNASSIGNED_QUEUE_DATE_TO,
|
||||
} from "@/domain/work-orders/utils/board-query-params";
|
||||
import { workOrderDeepLinkUrl } from "@/domain/work-orders/utils/work-order-deep-link";
|
||||
|
||||
/** Every open, unassigned work order in any week, through the existing Work Orders filter params. */
|
||||
export function unassignedQueueUrl(): string {
|
||||
const search = new URLSearchParams();
|
||||
search.set("dateFrom", UNASSIGNED_QUEUE_DATE_FROM);
|
||||
search.set("dateTo", UNASSIGNED_QUEUE_DATE_TO);
|
||||
search.set("dispatchers", ASSIGNEE_FILTER_UNASSIGNED);
|
||||
search.set("statuses", OPEN_WIZARD_STATUSES.join(","));
|
||||
return workOrderDrilldownUrl(search, "notification");
|
||||
}
|
||||
|
||||
/** The work order form with vendor assignment open — where the vendor reminders always sent "Choose vendor". */
|
||||
export function chooseVendorUrl(workOrderId: number): string {
|
||||
return `/workorders/edit/${workOrderId}?assignVendor=1`;
|
||||
}
|
||||
|
||||
export function workOrderViewUrl(workOrderId: number): string {
|
||||
return `/workorders/${workOrderId}`;
|
||||
}
|
||||
|
||||
export function notificationTargetUrl(
|
||||
item: Pick<NotificationItem, "reason" | "target">,
|
||||
): string | null {
|
||||
const { target } = item;
|
||||
if (target.kind === "queue") {
|
||||
return target.queue === "unassigned" ? unassignedQueueUrl() : null;
|
||||
}
|
||||
if (target.workOrderId == null) {
|
||||
return null;
|
||||
}
|
||||
if (item.reason === "no-vendor") {
|
||||
return chooseVendorUrl(target.workOrderId);
|
||||
}
|
||||
return workOrderDeepLinkUrl(target.workOrderId, target.tab);
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import {
|
||||
NOTIFICATION_REASON_ORDER,
|
||||
NOTIFICATION_SEVERITY_RANK,
|
||||
type NotificationItem,
|
||||
type NotificationSection,
|
||||
} from "@/domain/notifications/types/notification-feed";
|
||||
|
||||
const reasonOrder: ReadonlyMap<string, number> = new Map(
|
||||
NOTIFICATION_REASON_ORDER.map((reason, index) => [reason, index]),
|
||||
);
|
||||
|
||||
function reasonIndex(reason: string): number {
|
||||
return reasonOrder.get(reason) ?? NOTIFICATION_REASON_ORDER.length;
|
||||
}
|
||||
|
||||
function highestSeverity(items: NotificationItem[]): NotificationItem["severity"] {
|
||||
return items.reduce<NotificationItem["severity"]>(
|
||||
(highest, item) =>
|
||||
NOTIFICATION_SEVERITY_RANK[item.severity] > NOTIFICATION_SEVERITY_RANK[highest]
|
||||
? item.severity
|
||||
: highest,
|
||||
"Low",
|
||||
);
|
||||
}
|
||||
|
||||
export function isDismissable(item: NotificationItem): boolean {
|
||||
return item.rowType === "dismissable";
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies this session's dismissals to the server feed: dismissed rows leave, section counts drop
|
||||
* by the work orders those rows represented, empty sections disappear, and sections re-sort by
|
||||
* their highest remaining severity with the fixed reason order breaking ties.
|
||||
*/
|
||||
export function visibleNotificationSections(
|
||||
sections: NotificationSection[],
|
||||
dismissedIds: ReadonlySet<string>,
|
||||
): NotificationSection[] {
|
||||
return sections
|
||||
.map((section) => {
|
||||
const items = section.items.filter(
|
||||
(item) => !isDismissable(item) || !dismissedIds.has(item.id),
|
||||
);
|
||||
const dismissedCount = section.items
|
||||
.filter((item) => !items.includes(item))
|
||||
.reduce((total, item) => total + item.count, 0);
|
||||
return {
|
||||
...section,
|
||||
items,
|
||||
count: Math.max(section.count - dismissedCount, 0),
|
||||
severity: highestSeverity(items),
|
||||
};
|
||||
})
|
||||
.filter((section) => section.items.length > 0)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
NOTIFICATION_SEVERITY_RANK[right.severity] - NOTIFICATION_SEVERITY_RANK[left.severity] ||
|
||||
reasonIndex(left.reason) - reasonIndex(right.reason),
|
||||
);
|
||||
}
|
||||
|
|
@ -106,28 +106,61 @@ export const upliftsApi = {
|
|||
id: string | number,
|
||||
fallbackFileName = "uplift-evidence",
|
||||
): Promise<void> => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await apiRequestRaw(
|
||||
"get",
|
||||
evidenceUrl(id),
|
||||
undefined,
|
||||
"upliftsApi.downloadEvidence",
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HTTPError) {
|
||||
throw evidenceHttpError(error.response.status);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const response = await fetchEvidence(id, "upliftsApi.downloadEvidence");
|
||||
const blob = await response.blob();
|
||||
const fileName = readContentDispositionFilename(response, fallbackFileName);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = window.document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
saveBlob(blob, readContentDispositionFilename(response, fallbackFileName));
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders the evidence in `tab`, which the caller opens synchronously on click so
|
||||
* popup blockers allow it. Only inert types render inline on the app origin; any
|
||||
* other type (HTML, SVG, ...) is downloaded instead and the tab is closed.
|
||||
*/
|
||||
openEvidence: async (
|
||||
id: string | number,
|
||||
tab: Window | null,
|
||||
fallbackFileName = "uplift-evidence",
|
||||
): Promise<void> => {
|
||||
const response = await fetchEvidence(id, "upliftsApi.openEvidence");
|
||||
const blob = await response.blob();
|
||||
const type = blob.type.split(";")[0].trim().toLowerCase();
|
||||
if (tab == null || !INLINE_EVIDENCE_TYPES.has(type)) {
|
||||
tab?.close();
|
||||
saveBlob(blob, readContentDispositionFilename(response, fallbackFileName));
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(new Blob([blob], { type }));
|
||||
tab.location.href = url;
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), EVIDENCE_URL_TTL_MS);
|
||||
},
|
||||
};
|
||||
|
||||
const INLINE_EVIDENCE_TYPES = new Set([
|
||||
"application/pdf",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
]);
|
||||
|
||||
const EVIDENCE_URL_TTL_MS = 60_000;
|
||||
|
||||
async function fetchEvidence(id: string | number, operation: string): Promise<Response> {
|
||||
try {
|
||||
return await apiRequestRaw("get", evidenceUrl(id), undefined, operation);
|
||||
} catch (error) {
|
||||
if (error instanceof HTTPError) {
|
||||
throw evidenceHttpError(error.response.status);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function saveBlob(blob: Blob, fileName: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = window.document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,6 +116,9 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem {
|
|||
"trade",
|
||||
"Trade",
|
||||
),
|
||||
technicianName: readString(item, "technicianName", "TechnicianName"),
|
||||
workOrderDispatcherName: readString(item, "workOrderDispatcherName", "WorkOrderDispatcherName"),
|
||||
workOrderScheduledDate: readString(item, "workOrderScheduledDate", "WorkOrderScheduledDate"),
|
||||
attachmentCount: readNumber(
|
||||
item,
|
||||
"attachmentCount",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ export interface UpliftQueueItem extends UpliftRequest {
|
|||
woNumber: string;
|
||||
site: string;
|
||||
serviceName: string;
|
||||
technicianName: string;
|
||||
workOrderDispatcherName: string;
|
||||
workOrderScheduledDate: string;
|
||||
attachmentCount: number | null;
|
||||
approvedOnWoAuto: number | null;
|
||||
approvedOnWoAdmin: number | null;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import type {
|
|||
VendorAvailability,
|
||||
VendorAssignmentPayload,
|
||||
VendorInsight,
|
||||
VendorNotification,
|
||||
} from "@/domain/vendor-operations/types/vendor-operations";
|
||||
|
||||
const base = "vendor-operations";
|
||||
|
|
@ -43,13 +42,6 @@ async function saveDownload(path: string, fileName: string, params: InsightParam
|
|||
}
|
||||
|
||||
export const vendorOperationsApi = {
|
||||
notifications: async (): Promise<VendorNotification[]> => {
|
||||
const response = await apiGet<{ items?: VendorNotification[]; Items?: VendorNotification[] }>(
|
||||
`${base}/notifications`,
|
||||
);
|
||||
return response.items ?? response.Items ?? [];
|
||||
},
|
||||
|
||||
availability: async (params: {
|
||||
start?: string;
|
||||
end?: string;
|
||||
|
|
|
|||
|
|
@ -1,28 +1,3 @@
|
|||
export interface VendorNotificationAction {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface VendorNotification {
|
||||
id: string;
|
||||
type: "NoVendor" | "VendorConflict";
|
||||
severity: "Medium";
|
||||
group: "No Vendor" | "Vendor Conflict";
|
||||
title: string;
|
||||
workOrderId?: number;
|
||||
workOrderNumber?: string;
|
||||
triggeredAt: string;
|
||||
scheduledAt?: string;
|
||||
action?: VendorNotificationAction;
|
||||
actions?: VendorNotificationAction[];
|
||||
workOrders?: Array<{
|
||||
workOrderId: number;
|
||||
workOrderNumber?: string;
|
||||
start: string;
|
||||
end: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface VendorAvailability {
|
||||
id: number;
|
||||
companyName: string;
|
||||
|
|
|
|||
81
src/domain/work-orders/api/work-order-board-patch-api.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
|
||||
import { apiRequestRaw } from "@/api/api";
|
||||
import {
|
||||
BoardPatchConflictError,
|
||||
BoardPatchValidationError,
|
||||
} from "@/domain/work-orders/errors/board-patch-errors";
|
||||
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import type {
|
||||
WorkOrderBoardPatchPayload,
|
||||
WorkOrderPocPatchPayload,
|
||||
} from "@/domain/work-orders/types/work-order-board";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
/** Shared request/error handling for board row PATCH endpoints (board + poc). */
|
||||
async function patchBoardRow(
|
||||
operationName: string,
|
||||
method: "patch",
|
||||
url: string,
|
||||
payload: WorkOrderBoardPatchPayload | WorkOrderPocPatchPayload,
|
||||
): Promise<WorkOrderTableRow> {
|
||||
const response = await apiRequestRaw(
|
||||
method,
|
||||
url,
|
||||
{ json: payload, throwHttpErrors: false },
|
||||
operationName,
|
||||
);
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
data = undefined;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return mapWorkOrderTableRow(data);
|
||||
}
|
||||
|
||||
const record = typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
|
||||
|
||||
if (response.status === 409) {
|
||||
const currentStateRaw = record.currentState ?? record.CurrentState;
|
||||
const currentState = currentStateRaw != null ? mapWorkOrderTableRow(currentStateRaw) : null;
|
||||
throw new BoardPatchConflictError(
|
||||
typeof record.message === "string"
|
||||
? record.message
|
||||
: "The record was modified by another user. Refresh and retry.",
|
||||
currentState,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 422) {
|
||||
throw new BoardPatchValidationError(
|
||||
typeof record.code === "string" ? record.code : "ValidationError",
|
||||
typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data),
|
||||
);
|
||||
}
|
||||
|
||||
throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
|
||||
}
|
||||
|
||||
export const workOrderBoardPatchApi = {
|
||||
patchBoardField: async (
|
||||
id: string | number,
|
||||
payload: WorkOrderBoardPatchPayload,
|
||||
): Promise<WorkOrderTableRow> =>
|
||||
patchBoardRow(
|
||||
"workOrdersApi.patchBoardField",
|
||||
"patch",
|
||||
API_PATHS.workOrder.boardPatch(id),
|
||||
payload,
|
||||
),
|
||||
|
||||
/** Manual POC override — same row/error contract as the board patch. */
|
||||
updatePoc: async (
|
||||
id: string | number,
|
||||
payload: WorkOrderPocPatchPayload,
|
||||
): Promise<WorkOrderTableRow> =>
|
||||
patchBoardRow("workOrdersApi.updatePoc", "patch", API_PATHS.workOrder.pocPatch(id), payload),
|
||||
};
|
||||
|
|
@ -1,12 +1,8 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
|
||||
import { apiDelete, apiGet, apiPost, apiPostForm, apiPut, apiRequestRaw } from "@/api/api";
|
||||
import { apiDelete, apiGet, apiPost, apiPostForm, apiPut } from "@/api/api";
|
||||
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
||||
import {
|
||||
BoardPatchConflictError,
|
||||
BoardPatchValidationError,
|
||||
} from "@/domain/work-orders/errors/board-patch-errors";
|
||||
import { workOrderBoardDocumentsApi } from "@/domain/work-orders/api/work-order-board-documents-api";
|
||||
import { workOrderBoardPatchApi } from "@/domain/work-orders/api/work-order-board-patch-api";
|
||||
import {
|
||||
mapDispatchComment,
|
||||
mapDispatchDetail,
|
||||
|
|
@ -20,7 +16,6 @@ import {
|
|||
mapWorkOrderDetail,
|
||||
mapWorkOrderFormToCreatePayload,
|
||||
mapWorkOrderFormToUpdateFormData,
|
||||
mapWorkOrderTableRow,
|
||||
mapWorkOrderTableRows,
|
||||
} from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
|
|
@ -42,7 +37,6 @@ import type {
|
|||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type {
|
||||
WorkOrderBoardListResult,
|
||||
WorkOrderBoardPatchPayload,
|
||||
WorkOrderBoardQueryParams,
|
||||
WorkOrderBoardResponseApi,
|
||||
WorkOrderBoardSearchQueryParams,
|
||||
|
|
@ -310,51 +304,10 @@ export const workOrdersApi = {
|
|||
return mapWorkOrderDetail(handleApiResponse(data));
|
||||
},
|
||||
|
||||
patchBoardField: async (
|
||||
id: string | number,
|
||||
payload: WorkOrderBoardPatchPayload,
|
||||
): Promise<WorkOrderTableRow> => {
|
||||
const response = await apiRequestRaw(
|
||||
"patch",
|
||||
API_PATHS.workOrder.boardPatch(id),
|
||||
{ json: payload, throwHttpErrors: false },
|
||||
"workOrdersApi.patchBoardField",
|
||||
);
|
||||
patchBoardField: workOrderBoardPatchApi.patchBoardField,
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
data = undefined;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return mapWorkOrderTableRow(data);
|
||||
}
|
||||
|
||||
const record =
|
||||
typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
|
||||
|
||||
if (response.status === 409) {
|
||||
const currentStateRaw = record.currentState ?? record.CurrentState;
|
||||
const currentState = currentStateRaw != null ? mapWorkOrderTableRow(currentStateRaw) : null;
|
||||
throw new BoardPatchConflictError(
|
||||
typeof record.message === "string"
|
||||
? record.message
|
||||
: "The record was modified by another user. Refresh and retry.",
|
||||
currentState,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 422) {
|
||||
throw new BoardPatchValidationError(
|
||||
typeof record.code === "string" ? record.code : "ValidationError",
|
||||
typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data),
|
||||
);
|
||||
}
|
||||
|
||||
throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
|
||||
},
|
||||
/** Manual POC override — same row/error contract as the board patch. */
|
||||
updatePoc: workOrderBoardPatchApi.updatePoc,
|
||||
|
||||
update: async (
|
||||
id: string | number,
|
||||
|
|
|
|||
|
|
@ -28,9 +28,6 @@ const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
|
|||
"techPhone",
|
||||
"location",
|
||||
"locationId",
|
||||
"pocName",
|
||||
"pocPhone",
|
||||
"pocNotes",
|
||||
"title",
|
||||
"priority",
|
||||
"apptStart",
|
||||
|
|
@ -53,16 +50,44 @@ export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {
|
|||
return keys.length > 0 && keys.every((key) => LOCAL_ONLY_KEYS.has(key));
|
||||
}
|
||||
|
||||
/** Composite manual POC override op routed to PATCH …/poc. */
|
||||
export type BoardPocOp = {
|
||||
kind: "poc";
|
||||
pocName?: string;
|
||||
pocPhone?: string;
|
||||
pocNotes?: string;
|
||||
};
|
||||
|
||||
export type BoardOp = BoardFieldOp | BoardPocOp;
|
||||
|
||||
function expandPocOp(patch: WorkOrderTablePatch): BoardPocOp[] {
|
||||
const hasPocFields =
|
||||
patch.pocName !== undefined || patch.pocPhone !== undefined || patch.pocNotes !== undefined;
|
||||
if (!hasPocFields) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
kind: "poc",
|
||||
pocName: patch.pocName,
|
||||
pocPhone: patch.pocPhone,
|
||||
pocNotes: patch.pocNotes,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands a UI table patch into ordered board field ops (one PATCH each).
|
||||
* Schedule/week-only fields are emitted first in a stable order.
|
||||
* Expands a UI table patch into ordered board ops (one request each).
|
||||
* Schedule/week-only fields are emitted first in a stable order; the composite
|
||||
* POC op follows, then scalar fields.
|
||||
*/
|
||||
export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardFieldOp[] {
|
||||
export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardOp[] {
|
||||
if (isLocalOnlyTablePatch(patch)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...expandScheduleBoardOps(patch), ...expandScalarBoardOps(patch)];
|
||||
return [...expandScheduleBoardOps(patch), ...expandPocOp(patch), ...expandScalarBoardOps(patch)];
|
||||
}
|
||||
|
||||
export function isDispatchBoardField(field: WorkOrderBoardEditableField): boolean {
|
||||
|
|
|
|||
|
|
@ -134,6 +134,14 @@ export interface WorkOrderBoardPatchPayload {
|
|||
primaryDispatchId?: number | null;
|
||||
}
|
||||
|
||||
/** Payload for PATCH /workorders/{id}/poc (manual POC override). */
|
||||
export interface WorkOrderPocPatchPayload {
|
||||
pocName: string;
|
||||
pocPhone: string;
|
||||
pocNotes: string;
|
||||
workOrderVersion: string;
|
||||
}
|
||||
|
||||
export type WorkOrderBoardEditableField =
|
||||
| "woNumber"
|
||||
| "workOrderType"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
buildBoardPatchPayload,
|
||||
expandTablePatchToBoardFieldOps,
|
||||
isLocalOnlyTablePatch,
|
||||
type BoardOp,
|
||||
} from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
|
||||
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import type {
|
||||
|
|
@ -53,6 +54,30 @@ export type WorkOrderTablePatchHandlers = {
|
|||
type PatchResult =
|
||||
{ kind: "local"; patch: WorkOrderTablePatch } | { kind: "board"; row: WorkOrderTableRow };
|
||||
|
||||
function isPocOp(op: BoardOp): op is Extract<BoardOp, { kind: "poc" }> {
|
||||
return "kind" in op && op.kind === "poc";
|
||||
}
|
||||
|
||||
async function applyBoardOp(
|
||||
id: string | number,
|
||||
op: BoardOp,
|
||||
row: WorkOrderTableRow,
|
||||
): Promise<WorkOrderTableRow> {
|
||||
if (isPocOp(op)) {
|
||||
if (!row.rowVersion) {
|
||||
throw new Error("workOrderVersion is required for board patch");
|
||||
}
|
||||
return workOrdersApi.updatePoc(id, {
|
||||
pocName: op.pocName ?? row.pocName,
|
||||
pocPhone: op.pocPhone ?? row.pocPhone,
|
||||
pocNotes: op.pocNotes ?? row.pocNotes,
|
||||
workOrderVersion: row.rowVersion,
|
||||
});
|
||||
}
|
||||
|
||||
return workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, row));
|
||||
}
|
||||
|
||||
async function applyBoardFieldOps(
|
||||
id: string | number,
|
||||
patch: WorkOrderTablePatch,
|
||||
|
|
@ -72,7 +97,7 @@ async function applyBoardFieldOps(
|
|||
let completedOps = 0;
|
||||
for (const op of ops) {
|
||||
try {
|
||||
latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
|
||||
latest = await applyBoardOp(id, op, latest);
|
||||
completedOps += 1;
|
||||
} catch (error) {
|
||||
if (completedOps > 0) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import type {
|
|||
/**
|
||||
* Fields compared for dirty state and included in Save patch.
|
||||
* Only keys that map to board PATCH (or required companions like dispatcherName).
|
||||
* Primary POC scalars and tech phone are not board-editable — exclude so Save cannot no-op.
|
||||
* Primary POC scalars persist via the dedicated POC endpoint;
|
||||
* tech phone is not board-editable — exclude so Save cannot no-op.
|
||||
* additionalContacts is board-editable (SH-218 PATCH field).
|
||||
*/
|
||||
export const SLIDE_OVER_EDIT_KEYS = [
|
||||
|
|
@ -32,6 +33,9 @@ export const SLIDE_OVER_EDIT_KEYS = [
|
|||
"vendorId",
|
||||
// company/tech are display companions; board persists vendor via vendorId only
|
||||
"status",
|
||||
"pocName",
|
||||
"pocPhone",
|
||||
"pocNotes",
|
||||
] as const satisfies ReadonlyArray<keyof WorkOrderTableRow>;
|
||||
|
||||
type EditKey = (typeof SLIDE_OVER_EDIT_KEYS)[number];
|
||||
|
|
|
|||
48
src/domain/work-orders/utils/work-order-deep-link.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { WORK_ORDERS_ROUTE } from "@/domain/dashboard/utils/work-order-drilldown-links";
|
||||
|
||||
const WORK_ORDER_PARAM = "wo";
|
||||
const TAB_PARAM = "tab";
|
||||
|
||||
export const WORK_ORDER_DEEP_LINK_TABS = [
|
||||
"info",
|
||||
"comments",
|
||||
"audit",
|
||||
"completion",
|
||||
"extras",
|
||||
] as const;
|
||||
|
||||
export type WorkOrderDeepLinkTab = (typeof WORK_ORDER_DEEP_LINK_TABS)[number];
|
||||
|
||||
export type WorkOrderDeepLink = {
|
||||
workOrderId: string;
|
||||
tab: WorkOrderDeepLinkTab;
|
||||
};
|
||||
|
||||
function isDeepLinkTab(value: string | null): value is WorkOrderDeepLinkTab {
|
||||
return WORK_ORDER_DEEP_LINK_TABS.some((tab) => tab === value);
|
||||
}
|
||||
|
||||
/** `/workorders?wo=<id>&tab=<tab>` opens that work order's slide-over on the given tab. */
|
||||
export function workOrderDeepLinkUrl(workOrderId: string | number, tab?: string | null): string {
|
||||
const search = new URLSearchParams({ [WORK_ORDER_PARAM]: String(workOrderId) });
|
||||
if (isDeepLinkTab(tab ?? null)) {
|
||||
search.set(TAB_PARAM, String(tab));
|
||||
}
|
||||
return `${WORK_ORDERS_ROUTE}?${search.toString()}`;
|
||||
}
|
||||
|
||||
export function parseWorkOrderDeepLink(params: URLSearchParams): WorkOrderDeepLink | null {
|
||||
const workOrderId = (params.get(WORK_ORDER_PARAM) ?? "").trim();
|
||||
if (!/^\d+$/.test(workOrderId)) {
|
||||
return null;
|
||||
}
|
||||
const tab = params.get(TAB_PARAM);
|
||||
return { workOrderId, tab: isDeepLinkTab(tab) ? tab : "info" };
|
||||
}
|
||||
|
||||
export function withoutWorkOrderDeepLink(params: URLSearchParams): URLSearchParams {
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete(WORK_ORDER_PARAM);
|
||||
next.delete(TAB_PARAM);
|
||||
return next;
|
||||
}
|
||||
|
|
@ -149,6 +149,10 @@ export const queryKeys = {
|
|||
detail: (id: string | number) => [...queryKeys.assets.all, "detail", id] as const,
|
||||
sites: () => [...queryKeys.assets.all, "sites"] as const,
|
||||
},
|
||||
notifications: {
|
||||
all: ["notifications"] as const,
|
||||
feed: () => [...queryKeys.notifications.all, "feed"] as const,
|
||||
},
|
||||
vendorPortal: {
|
||||
all: ["vendorPortal"] as const,
|
||||
session: (token: string) => [...queryKeys.vendorPortal.all, "session", token] as const,
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ import DashboardPage from "@/app/(protected)/dashboard";
|
|||
import {
|
||||
avetaPendingDrilldownSearch,
|
||||
scheduledTomorrowDrilldownSearch,
|
||||
workOrderDrilldownUrl,
|
||||
} from "@/domain/dashboard/utils/work-order-drilldown-links";
|
||||
import { addDaysIso, businessTodayIso } from "@/domain/dashboard/utils/dashboard-range-utils";
|
||||
|
||||
|
|
@ -161,7 +162,7 @@ describe("DashboardPage", () => {
|
|||
|
||||
const today = businessTodayIso();
|
||||
expect(navigate).toHaveBeenCalledWith(
|
||||
`/workorders?${scheduledTomorrowDrilldownSearch(today).toString()}`,
|
||||
workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch(today)),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Pending Uplifts/ }));
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ const approvedItem: UpliftQueueItem = {
|
|||
woNumber: "WO-55",
|
||||
site: "Site B",
|
||||
serviceName: "HVAC service",
|
||||
technicianName: "",
|
||||
workOrderDispatcherName: "",
|
||||
workOrderScheduledDate: "",
|
||||
attachmentCount: 3,
|
||||
approvedOnWoAuto: 100,
|
||||
approvedOnWoAdmin: 400,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
|
||||
import { ApiError } from "@/api/api-error";
|
||||
|
|
@ -80,6 +80,9 @@ const dispatcherItem: UpliftQueueItem = {
|
|||
woNumber: "WO-99",
|
||||
site: "Site A",
|
||||
serviceName: "Plumbing repair",
|
||||
technicianName: "Tom Tech",
|
||||
workOrderDispatcherName: "Dana Ruiz",
|
||||
workOrderScheduledDate: "2026-04-10T00:00:00",
|
||||
attachmentCount: null,
|
||||
approvedOnWoAuto: null,
|
||||
approvedOnWoAdmin: null,
|
||||
|
|
@ -113,17 +116,31 @@ describe("UpliftDetailModal permission failures (Dispatcher 403)", () => {
|
|||
woUpliftsMock.calls = 0;
|
||||
});
|
||||
|
||||
it("shows unavailable sections without an error toast and keeps Revoke admin-only", async () => {
|
||||
it("renders the work order from the queue item without a work-order fetch or error toast", async () => {
|
||||
renderWithGovernedClient();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(boardDetailMock.calls).toBeGreaterThan(0);
|
||||
expect(woUpliftsMock.calls).toBeGreaterThan(0);
|
||||
});
|
||||
expect(boardDetailMock.calls).toBe(0);
|
||||
const workOrder = screen.getByRole("region", { name: "Work order" });
|
||||
expect(within(workOrder).getByText("Dana Ruiz")).toBeInTheDocument();
|
||||
expect(within(workOrder).getByText("Gateway Plumbing · Tom Tech")).toBeInTheDocument();
|
||||
expect(within(workOrder).getByText("Apr 10, 2026")).toBeInTheDocument();
|
||||
expect(within(workOrder).queryByText("Unavailable")).not.toBeInTheDocument();
|
||||
expect(toastMocks.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Unavailable").length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
it("marks the approved-on-WO breakdown unavailable without a toast and keeps Revoke admin-only", async () => {
|
||||
renderWithGovernedClient();
|
||||
|
||||
const request = screen.getByRole("region", { name: "Uplift request" });
|
||||
for (const label of ["Auto-approved (within allowance)", "Admin-approved", "Total"]) {
|
||||
const row = within(request).getByText(label).parentElement as HTMLElement;
|
||||
await waitFor(() => {
|
||||
expect(within(row).getByText("Unavailable")).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
|
||||
const revoke = screen.getByRole("button", { name: "Revoke" });
|
||||
expect(revoke).toBeDisabled();
|
||||
|
|
|
|||
|
|
@ -1,18 +1,13 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { fireEvent, screen, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
|
||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const boardDetail = vi.hoisted(() => ({ data: null as Record<string, unknown> | null }));
|
||||
const woUplifts = vi.hoisted(() => ({ data: null as Array<Record<string, unknown>> | null }));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||
useWorkOrderBoardDetail: () => ({ data: boardDetail.data, isLoading: false, error: null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({
|
||||
useWorkOrderUplifts: () => ({ data: woUplifts.data, isLoading: false, error: null }),
|
||||
useWorkOrderUplifts: () => ({ data: woUplifts.data, isLoading: false, isError: false }),
|
||||
}));
|
||||
|
||||
const canApproveState = vi.hoisted(() => ({ data: true as boolean | undefined }));
|
||||
|
|
@ -28,7 +23,7 @@ const baseItem: UpliftQueueItem = {
|
|||
requestedNTE: 250,
|
||||
delta: 150,
|
||||
vendorReason: "",
|
||||
requestedAt: "2026-01-15T10:00:00Z",
|
||||
requestedAt: "2026-01-15T15:00:00Z",
|
||||
requestedByVendorName: "Gateway",
|
||||
decidedAt: "",
|
||||
decidedByName: "",
|
||||
|
|
@ -50,6 +45,9 @@ const baseItem: UpliftQueueItem = {
|
|||
woNumber: "WO-99",
|
||||
site: "Site A",
|
||||
serviceName: "Plumbing repair",
|
||||
technicianName: "",
|
||||
workOrderDispatcherName: "",
|
||||
workOrderScheduledDate: "",
|
||||
attachmentCount: null,
|
||||
approvedOnWoAuto: null,
|
||||
approvedOnWoAdmin: null,
|
||||
|
|
@ -58,76 +56,150 @@ const baseItem: UpliftQueueItem = {
|
|||
};
|
||||
|
||||
function renderModal(overrides: Partial<UpliftQueueItem> = {}, canRevoke = true) {
|
||||
const onApprove = vi.fn();
|
||||
const onReject = vi.fn();
|
||||
const onRevoke = vi.fn();
|
||||
const onOpenAttachment = vi.fn();
|
||||
const callbacks = {
|
||||
onClose: vi.fn(),
|
||||
onApprove: vi.fn(),
|
||||
onReject: vi.fn(),
|
||||
onRevoke: vi.fn(),
|
||||
onOpenAttachment: vi.fn(),
|
||||
};
|
||||
renderWithProviders(
|
||||
<UpliftDetailModal
|
||||
item={{ ...baseItem, ...overrides }}
|
||||
canRevoke={canRevoke}
|
||||
approvePending={false}
|
||||
revokePending={false}
|
||||
onClose={vi.fn()}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
onRevoke={onRevoke}
|
||||
onOpenAttachment={onOpenAttachment}
|
||||
{...callbacks}
|
||||
/>,
|
||||
);
|
||||
return { onApprove, onReject, onRevoke, onOpenAttachment };
|
||||
return callbacks;
|
||||
}
|
||||
|
||||
function section(name: string): HTMLElement {
|
||||
return screen.getByRole("region", { name });
|
||||
}
|
||||
|
||||
function fieldValue(container: HTMLElement, label: string): string {
|
||||
const labelNode = within(container).getByText(label);
|
||||
return labelNode.parentElement?.lastElementChild?.textContent ?? "";
|
||||
}
|
||||
|
||||
describe("UpliftDetailModal", () => {
|
||||
beforeEach(() => {
|
||||
canApproveState.data = true;
|
||||
boardDetail.data = {
|
||||
info: { woNumber: "WO-99", site: "Site A", status: "Open", dueDate: "" },
|
||||
};
|
||||
woUplifts.data = [
|
||||
{ id: 1, status: "auto_approved", amount: 100 },
|
||||
{ id: 2, status: "approved", amount: 250 },
|
||||
];
|
||||
});
|
||||
|
||||
it("shows work order and request placeholders for a pending uplift", () => {
|
||||
it("organizes content into Work order and Uplift request divided by a single rule", () => {
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByText("Unscheduled")).toBeInTheDocument();
|
||||
expect(screen.getByText("No justification provided.")).toBeInTheDocument();
|
||||
expect(screen.getByText("No attachments")).toBeInTheDocument();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const regions = within(dialog).getAllByRole("region");
|
||||
expect(regions.map((region) => region.getAttribute("aria-label"))).toEqual([
|
||||
"Work order",
|
||||
"Uplift request",
|
||||
]);
|
||||
expect(within(dialog).getAllByRole("separator")).toHaveLength(1);
|
||||
expect(within(dialog).queryByText("Request")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens an evidence attachment through the provided callback", () => {
|
||||
it("renders the work order fields from the queue item", () => {
|
||||
renderModal({
|
||||
technicianName: "Tom Tech",
|
||||
workOrderDispatcherName: "Dana Ruiz",
|
||||
workOrderScheduledDate: "2026-04-10T00:00:00",
|
||||
});
|
||||
|
||||
const workOrder = section("Work order");
|
||||
expect(fieldValue(workOrder, "Service")).toBe("Plumbing repair");
|
||||
expect(fieldValue(workOrder, "Vendor / Technician")).toBe("Gateway Plumbing · Tom Tech");
|
||||
expect(fieldValue(workOrder, "Assigned Dispatcher")).toBe("Dana Ruiz");
|
||||
expect(fieldValue(workOrder, "Scheduled")).toBe("Apr 10, 2026");
|
||||
});
|
||||
|
||||
it("shows explicit placeholders for an empty work order, never a blank field", () => {
|
||||
renderModal({ serviceName: "", vendorCompanyName: "", technicianName: "" });
|
||||
|
||||
const workOrder = section("Work order");
|
||||
expect(fieldValue(workOrder, "Service")).toBe("Not specified");
|
||||
expect(fieldValue(workOrder, "Vendor / Technician")).toBe("Not assigned");
|
||||
expect(fieldValue(workOrder, "Assigned Dispatcher")).toBe("Unassigned");
|
||||
expect(fieldValue(workOrder, "Scheduled")).toBe("Unscheduled");
|
||||
});
|
||||
|
||||
it("renders requester, request date and time, and justification in a bordered block", () => {
|
||||
renderModal({ vendorReason: "Scope grew after inspection" });
|
||||
|
||||
const request = section("Uplift request");
|
||||
expect(fieldValue(request, "Requested By")).toBe("Gateway");
|
||||
// The instant renders in the viewer's zone, so the calendar day is derived, not fixed.
|
||||
const localDay = new Date(baseItem.requestedAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
expect(fieldValue(request, "Requested At")).toMatch(
|
||||
new RegExp(`^${localDay} · \\d{1,2}:\\d{2} [AP]M$`, "u"),
|
||||
);
|
||||
expect(screen.getByTestId("uplift-justification")).toHaveTextContent(
|
||||
"Scope grew after inspection",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows explicit request placeholders when requester, date, notes and files are missing", () => {
|
||||
renderModal({ requestedByVendorName: "", requestedAt: "" });
|
||||
|
||||
const request = section("Uplift request");
|
||||
expect(fieldValue(request, "Requested By")).toBe("Unknown requester");
|
||||
expect(fieldValue(request, "Requested At")).toBe("Not recorded");
|
||||
const placeholder = within(screen.getByTestId("uplift-justification")).getByText(
|
||||
"No justification provided.",
|
||||
);
|
||||
expect(placeholder).toHaveClass("italic");
|
||||
expect(within(request).getByText("No attachments")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders attachments as a horizontal scrolling chip row that opens the file", () => {
|
||||
const { onOpenAttachment } = renderModal({
|
||||
evidenceDocumentId: "document-1",
|
||||
evidenceFileName: "quote.pdf",
|
||||
attachmentCount: 1,
|
||||
attachmentCount: 3,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("quote.pdf"));
|
||||
const row = screen.getByTestId("uplift-attachments");
|
||||
expect(row).toHaveStyle({ overflowX: "auto", flexWrap: "nowrap" });
|
||||
expect(within(row).getByText("+2")).toBeInTheDocument();
|
||||
fireEvent.click(within(row).getByText("quote.pdf"));
|
||||
expect(onOpenAttachment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ evidenceDocumentId: "document-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the work order uplifts for the approved-on-WO breakdown", () => {
|
||||
it("shows the approved-on-WO exposure breakdown inside the uplift request", () => {
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByText("Auto-approved")).toBeInTheDocument();
|
||||
expect(screen.getByText("Admin-approved")).toBeInTheDocument();
|
||||
expect(screen.getByText("$100.00")).toBeInTheDocument();
|
||||
expect(screen.getByText("$250.00")).toBeInTheDocument();
|
||||
expect(screen.getByText("$350.00")).toBeInTheDocument();
|
||||
const request = section("Uplift request");
|
||||
expect(within(request).getByText("Approved on WO")).toBeInTheDocument();
|
||||
expect(within(request).getByText("Auto-approved (within allowance)")).toBeInTheDocument();
|
||||
expect(within(request).getByText("Admin-approved")).toBeInTheDocument();
|
||||
expect(within(request).getByText("$100.00")).toBeInTheDocument();
|
||||
expect(within(request).getByText("$350.00")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers status-specific actions for a pending uplift", () => {
|
||||
it("offers Reject then Approve for a pending uplift", () => {
|
||||
const { onApprove, onReject } = renderModal();
|
||||
|
||||
const footerButtons = screen
|
||||
.getAllByRole("button")
|
||||
.map((button) => button.textContent)
|
||||
.filter((label) => label === "Reject" || label === "Approve" || label === "Revoke");
|
||||
expect(footerButtons).toEqual(["Reject", "Approve"]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
|
||||
expect(onApprove).toHaveBeenCalled();
|
||||
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
|
||||
expect(onReject).toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -148,12 +220,17 @@ describe("UpliftDetailModal", () => {
|
|||
expect(onReject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers Revoke for an approved uplift and disables it on a closed work order", () => {
|
||||
it("offers Revoke for an approved uplift and disables it with a tooltip on a closed work order", async () => {
|
||||
const { onRevoke } = renderModal({ status: "Approved", workOrderClosed: true });
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument();
|
||||
const revoke = screen.getByRole("button", { name: "Revoke" });
|
||||
expect(revoke).toBeDisabled();
|
||||
fireEvent.mouseOver(revoke);
|
||||
expect(
|
||||
await screen.findByText("This work order is closed. Uplifts can no longer be revoked."),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(revoke);
|
||||
expect(onRevoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -169,6 +246,26 @@ describe("UpliftDetailModal", () => {
|
|||
expect(onRevoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["Rejected", "Withdrawn"])("is a read-only record with no actions when %s", (status) => {
|
||||
renderModal({ status });
|
||||
|
||||
for (const name of ["Approve", "Reject", "Revoke"]) {
|
||||
expect(screen.queryByRole("button", { name })).not.toBeInTheDocument();
|
||||
}
|
||||
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes from the header X without triggering any decision", () => {
|
||||
const callbacks = renderModal();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
|
||||
expect(callbacks.onApprove).not.toHaveBeenCalled();
|
||||
expect(callbacks.onReject).not.toHaveBeenCalled();
|
||||
expect(callbacks.onRevoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not render a heading nested inside the dialog title heading", () => {
|
||||
renderModal();
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ const pendingItem: UpliftQueueItem = {
|
|||
woNumber: "WO-99",
|
||||
site: "Site A",
|
||||
serviceName: "Plumbing repair",
|
||||
technicianName: "",
|
||||
workOrderDispatcherName: "",
|
||||
workOrderScheduledDate: "",
|
||||
attachmentCount: null,
|
||||
approvedOnWoAuto: null,
|
||||
approvedOnWoAdmin: null,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { act, renderHook } from "@testing-library/react";
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { toast } from "react-toastify";
|
||||
import { useSlideOverEditState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-edit-state";
|
||||
import type { SlideOverTab } from "@/app/(protected)/workorders/_components/detail/use-work-order-slide-over";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
vendorAssignedMessage,
|
||||
|
|
@ -111,6 +112,34 @@ describe("useSlideOverEditState completed lock", () => {
|
|||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies a repeated same-row tab request without resetting the draft", () => {
|
||||
const open = baseRow();
|
||||
const setTab = vi.fn();
|
||||
const { result, rerender } = renderHook(
|
||||
({ initialTab, openRequestKey }) =>
|
||||
useSlideOverEditState({
|
||||
row: open,
|
||||
editMode: false,
|
||||
infoSource: open,
|
||||
onSave: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
closeDisabled: false,
|
||||
setTab,
|
||||
initialTab,
|
||||
openRequestKey,
|
||||
}),
|
||||
{ initialProps: { initialTab: "info" as SlideOverTab, openRequestKey: 1 } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDraftChange({ woNumber: "edited" });
|
||||
});
|
||||
rerender({ initialTab: "extras", openRequestKey: 2 });
|
||||
|
||||
expect(setTab).toHaveBeenLastCalledWith("extras");
|
||||
expect(result.current.activeDraft?.woNumber).toBe("edited");
|
||||
});
|
||||
|
||||
it("toasts vendor assigned after a successful save that includes vendorId", () => {
|
||||
const onSave = vi.fn();
|
||||
const open = baseRow();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { MemoryRouter, useLocation, useNavigate } from "react-router";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters";
|
||||
import { useWorkOrderDeepLink } from "@/app/(protected)/workorders/_hooks/use-work-order-deep-link";
|
||||
|
||||
const detailState = vi.hoisted(() => ({
|
||||
data: undefined as unknown,
|
||||
error: null as Error | null,
|
||||
requestedIds: [] as (string | number | undefined)[],
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||
useWorkOrderBoardDetail: (id: string | number | undefined) => {
|
||||
detailState.requestedIds.push(id);
|
||||
return { data: id == null ? undefined : detailState.data, error: detailState.error };
|
||||
},
|
||||
}));
|
||||
|
||||
function wrapper(route: string) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("work order deep link", () => {
|
||||
beforeEach(() => {
|
||||
detailState.data = { info: { id: 42, woNumber: "1042" } };
|
||||
detailState.error = null;
|
||||
detailState.requestedIds = [];
|
||||
});
|
||||
|
||||
it("opens the linked work order on the requested tab and clears the link", async () => {
|
||||
const onOpen = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
useWorkOrderDeepLink(onOpen);
|
||||
return useLocation();
|
||||
},
|
||||
{ wrapper: wrapper("/workorders?wo=42&tab=extras") },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.search).toBe(""));
|
||||
expect(detailState.requestedIds).toContain("42");
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
expect(onOpen).toHaveBeenCalledWith({ id: 42, woNumber: "1042" }, false, "extras");
|
||||
});
|
||||
|
||||
it("falls back to the info tab for an unknown tab", async () => {
|
||||
const onOpen = vi.fn();
|
||||
renderHook(() => useWorkOrderDeepLink(onOpen), {
|
||||
wrapper: wrapper("/workorders?wo=42&tab=nope"),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onOpen).toHaveBeenCalledWith(expect.anything(), false, "info"));
|
||||
});
|
||||
|
||||
it("clears the link without opening anything when the work order cannot load", async () => {
|
||||
detailState.data = undefined;
|
||||
detailState.error = new Error("not found");
|
||||
const onOpen = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
useWorkOrderDeepLink(onOpen);
|
||||
return useLocation();
|
||||
},
|
||||
{ wrapper: wrapper("/workorders?wo=42") },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.search).toBe(""));
|
||||
expect(onOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a board URL without a work order link", () => {
|
||||
const onOpen = vi.fn();
|
||||
renderHook(() => useWorkOrderDeepLink(onOpen), { wrapper: wrapper("/workorders?tab=extras") });
|
||||
|
||||
expect(detailState.requestedIds.every((id) => id == null)).toBe(true);
|
||||
expect(onOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("board drilldown links", () => {
|
||||
it("applies a second drilldown link while the board stays mounted", async () => {
|
||||
const onApply = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
useDashboardDrilldownFilters(onApply);
|
||||
return { navigate: useNavigate(), location: useLocation() };
|
||||
},
|
||||
{ wrapper: wrapper("/workorders?dateFrom=2026-09-01&dateTo=2026-09-30") },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onApply).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(result.current.location.search).toBe(""));
|
||||
|
||||
act(() => {
|
||||
void result.current.navigate(
|
||||
"/workorders?dateFrom=1970-01-01&dateTo=2099-12-31&dispatchers=__unassigned",
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onApply).toHaveBeenCalledTimes(2));
|
||||
expect(onApply.mock.calls[1]?.[0]).toMatchObject({
|
||||
customFrom: "1970-01-01",
|
||||
customTo: "2099-12-31",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps dashboard provenance separate from notification drilldowns", async () => {
|
||||
const onApply = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const fromDashboard = useDashboardDrilldownFilters(onApply);
|
||||
return { fromDashboard, navigate: useNavigate(), location: useLocation() };
|
||||
},
|
||||
{
|
||||
wrapper: wrapper("/workorders?dateFrom=2026-09-01&dateTo=2026-09-30&fromDashboard=1"),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onApply).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(result.current.location.search).toBe(""));
|
||||
expect(result.current.fromDashboard).toBe(true);
|
||||
|
||||
act(() => {
|
||||
void result.current.navigate(
|
||||
"/workorders?dateFrom=1970-01-01&dateTo=2099-12-31&dispatchers=__unassigned",
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onApply).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(result.current.fromDashboard).toBe(false));
|
||||
});
|
||||
});
|
||||
|
|
@ -150,7 +150,7 @@ function DayAssignHarness({
|
|||
dispatcherId: id,
|
||||
dispatcherName: name,
|
||||
});
|
||||
const patched = await patchBoardField(item.id, ops[0]!);
|
||||
const patched = await patchBoardField(item.id, ops[0] as { field: string });
|
||||
setRow({ ...patched, dispatcherId: id, dispatcherName: name });
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
362
src/test/components/notifications/notification-center.test.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useLocation } from "react-router";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import NotificationsPage from "@/app/(protected)/notifications";
|
||||
import { NotificationBell } from "@/components/notifications/notification-bell";
|
||||
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const apiGet = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/api", () => ({ apiGet }));
|
||||
|
||||
type ItemInput = {
|
||||
id: string;
|
||||
reason: string;
|
||||
severity: "Critical" | "High" | "Medium" | "Low";
|
||||
title: string;
|
||||
count?: number;
|
||||
rowType?: "dismissable" | "acknowledge";
|
||||
target: { kind: "workOrder" | "queue"; workOrderId?: number; tab?: string; queue?: string };
|
||||
workOrders?: { id: number; number: string }[];
|
||||
};
|
||||
|
||||
function item(input: ItemInput) {
|
||||
return {
|
||||
rowType: "dismissable" as ItemInput["rowType"],
|
||||
count: 1,
|
||||
triggeredAt: "2026-09-18T12:00:00Z",
|
||||
workOrders: [] as { id: number; number: string }[],
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
function section(reason: string, label: string, items: ReturnType<typeof item>[]) {
|
||||
const severity = items[0]?.severity ?? "Low";
|
||||
return {
|
||||
reason,
|
||||
label,
|
||||
severity,
|
||||
count: items.reduce((sum, row) => sum + row.count, 0),
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
const unassigned = item({
|
||||
id: "unassigned",
|
||||
reason: "unassigned",
|
||||
severity: "High",
|
||||
title: "3 work orders are unassigned",
|
||||
count: 3,
|
||||
target: { kind: "queue", queue: "unassigned" },
|
||||
});
|
||||
|
||||
const avetaFirst = item({
|
||||
id: "aveta-missing-41",
|
||||
reason: "aveta-missing",
|
||||
severity: "Medium",
|
||||
title: "WO #1041 is missing the Aveta document",
|
||||
target: { kind: "workOrder", workOrderId: 41, tab: "extras" },
|
||||
});
|
||||
|
||||
const avetaSecond = item({
|
||||
id: "aveta-missing-42",
|
||||
reason: "aveta-missing",
|
||||
severity: "Medium",
|
||||
title: "WO #1042 is missing the Aveta document",
|
||||
target: { kind: "workOrder", workOrderId: 42, tab: "extras" },
|
||||
});
|
||||
|
||||
const noVendor = item({
|
||||
id: "no-vendor-7",
|
||||
reason: "no-vendor",
|
||||
severity: "Medium",
|
||||
title: "WO #1007 starts within 48h with no vendor assigned",
|
||||
target: { kind: "workOrder", workOrderId: 7, tab: "info" },
|
||||
});
|
||||
|
||||
const acknowledgeRow = item({
|
||||
id: "sla-9",
|
||||
reason: "sla",
|
||||
severity: "Critical",
|
||||
rowType: "acknowledge",
|
||||
title: "WO #1009 missed its response deadline",
|
||||
target: { kind: "workOrder", workOrderId: 9, tab: "info" },
|
||||
});
|
||||
|
||||
function feed(...sections: ReturnType<typeof section>[]) {
|
||||
return { generatedAt: "2026-09-18T12:00:00Z", sections };
|
||||
}
|
||||
|
||||
const standardFeed = feed(
|
||||
section("unassigned", "Unassigned", [unassigned]),
|
||||
section("no-vendor", "No Vendor", [noVendor]),
|
||||
section("aveta-missing", "Aveta Missing", [avetaFirst, avetaSecond]),
|
||||
);
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <output aria-label="Current location">{`${location.pathname}${location.search}`}</output>;
|
||||
}
|
||||
|
||||
function renderBell() {
|
||||
return renderWithProviders(
|
||||
<NotificationSessionProvider>
|
||||
<NotificationBell />
|
||||
<LocationProbe />
|
||||
</NotificationSessionProvider>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
}
|
||||
|
||||
async function openPanel(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(await screen.findByRole("button", { name: /^Notifications, \d+ unread$/ }));
|
||||
return screen.findByRole("dialog");
|
||||
}
|
||||
|
||||
function sectionLabels(panel: HTMLElement) {
|
||||
return within(panel)
|
||||
.getAllByRole("region")
|
||||
.map((region) => region.getAttribute("aria-label"));
|
||||
}
|
||||
|
||||
describe("notification center", () => {
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
apiGet.mockResolvedValue(standardFeed);
|
||||
});
|
||||
|
||||
it("requests the signed-in user's feed and counts every unread item on the bell", async () => {
|
||||
renderBell();
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Notifications, 4 unread" }),
|
||||
).toBeInTheDocument();
|
||||
expect(apiGet).toHaveBeenCalledWith("notifications");
|
||||
});
|
||||
|
||||
it("shows labeled sections with live counts in severity then reason order", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBell();
|
||||
|
||||
const panel = await openPanel(user);
|
||||
|
||||
expect(sectionLabels(panel)).toEqual(["Unassigned, 3", "No Vendor, 1", "Aveta Missing, 2"]);
|
||||
const aveta = within(panel).getByRole("region", { name: "Aveta Missing, 2" });
|
||||
expect(within(aveta).getByRole("heading", { name: "Aveta Missing" })).toBeInTheDocument();
|
||||
expect(within(aveta).getAllByRole("listitem")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("never renders an empty section and shows the empty state when nothing is left", async () => {
|
||||
apiGet.mockResolvedValue(
|
||||
feed(section("unassigned", "Unassigned", [unassigned]), section("due-soon", "Due Soon", [])),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderBell();
|
||||
|
||||
const panel = await openPanel(user);
|
||||
expect(sectionLabels(panel)).toEqual(["Unassigned, 3"]);
|
||||
|
||||
await user.click(within(panel).getByRole("button", { name: "Dismiss notification" }));
|
||||
|
||||
expect(within(panel).queryAllByRole("region")).toHaveLength(0);
|
||||
expect(within(panel).getByText("Nothing needs your attention")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Notifications", hidden: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("styles unread rows apart from read ones and marks everything read on request", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBell();
|
||||
|
||||
const panel = await openPanel(user);
|
||||
const rows = within(panel).getAllByRole("listitem");
|
||||
expect(rows.every((row) => row.dataset.unread === "true")).toBe(true);
|
||||
expect(within(panel).getAllByText("Unread:", { exact: false })).toHaveLength(4);
|
||||
|
||||
await user.click(within(panel).getByRole("button", { name: "Mark all read" }));
|
||||
|
||||
expect(
|
||||
within(panel)
|
||||
.getAllByRole("listitem")
|
||||
.every((row) => row.dataset.unread === "false"),
|
||||
).toBe(true);
|
||||
expect(within(panel).queryByText("Unread:", { exact: false })).not.toBeInTheDocument();
|
||||
expect(within(panel).getByRole("button", { name: "Mark all read" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Notifications", hidden: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("dismisses one item, lowering its section count, and keeps the dismissal to this session only", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = renderBell();
|
||||
|
||||
const panel = await openPanel(user);
|
||||
const aveta = within(panel).getByRole("region", { name: "Aveta Missing, 2" });
|
||||
const firstRow = within(aveta).getByText(avetaFirst.title).closest("li");
|
||||
expect(firstRow).not.toBeNull();
|
||||
await user.click(
|
||||
within(firstRow as HTMLElement).getByRole("button", { name: "Dismiss notification" }),
|
||||
);
|
||||
|
||||
expect(within(panel).getByRole("region", { name: "Aveta Missing, 1" })).toBeInTheDocument();
|
||||
expect(within(panel).queryByText(avetaFirst.title)).not.toBeInTheDocument();
|
||||
expect(within(panel).getByText(avetaSecond.title)).toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
renderBell();
|
||||
const freshPanel = await openPanel(user);
|
||||
expect(within(freshPanel).getByText(avetaFirst.title)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("re-sorts sections after a dismissal changes which severity leads", async () => {
|
||||
apiGet.mockResolvedValue(
|
||||
feed(
|
||||
section("aveta-missing", "Aveta Missing", [
|
||||
item({ ...avetaFirst, severity: "High", title: "WO #1041 urgent" }),
|
||||
avetaSecond,
|
||||
]),
|
||||
section("no-vendor", "No Vendor", [noVendor]),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderBell();
|
||||
|
||||
const panel = await openPanel(user);
|
||||
expect(sectionLabels(panel)).toEqual(["Aveta Missing, 2", "No Vendor, 1"]);
|
||||
|
||||
const highRow = within(panel).getByText("WO #1041 urgent").closest("li") as HTMLElement;
|
||||
await user.click(within(highRow).getByRole("button", { name: "Dismiss notification" }));
|
||||
|
||||
expect(sectionLabels(panel)).toEqual(["No Vendor, 1", "Aveta Missing, 1"]);
|
||||
});
|
||||
|
||||
it("clears every dismissable item but keeps rows that need acknowledging", async () => {
|
||||
apiGet.mockResolvedValue(
|
||||
feed(
|
||||
section("sla", "SLA at Risk", [acknowledgeRow]),
|
||||
section("unassigned", "Unassigned", [unassigned]),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderBell();
|
||||
|
||||
const panel = await openPanel(user);
|
||||
const slaRow = within(panel).getByText(acknowledgeRow.title).closest("li") as HTMLElement;
|
||||
expect(within(slaRow).queryByRole("button", { name: "Dismiss notification" })).toBeNull();
|
||||
|
||||
await user.click(within(panel).getByRole("button", { name: "Clear all" }));
|
||||
|
||||
expect(sectionLabels(panel)).toEqual(["SLA at Risk, 1"]);
|
||||
expect(within(panel).getByText(acknowledgeRow.title)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the unassigned queue with the existing board filters and closes the panel", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBell();
|
||||
|
||||
const panel = await openPanel(user);
|
||||
await user.click(within(panel).getByRole("button", { name: /3 work orders are unassigned/ }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
const location = screen.getByRole("status", { name: "Current location" }).textContent ?? "";
|
||||
const url = new URL(location, "http://localhost");
|
||||
expect(url.pathname).toBe("/workorders");
|
||||
expect(url.searchParams.get("dispatchers")).toBe("__unassigned");
|
||||
expect(url.searchParams.get("dateFrom")).toBe("1970-01-01");
|
||||
expect(url.searchParams.get("dateTo")).toBe("2099-12-31");
|
||||
expect(url.searchParams.get("statuses")).toBeTruthy();
|
||||
expect(url.searchParams.get("fromDashboard")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Notifications, 3 unread" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens an Aveta item on that work order's upload tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBell();
|
||||
|
||||
const panel = await openPanel(user);
|
||||
await user.click(within(panel).getByRole("button", { name: new RegExp(avetaSecond.title) }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent(
|
||||
"/workorders?wo=42&tab=extras",
|
||||
);
|
||||
});
|
||||
|
||||
it("serves the same feed as a full page and sends No Vendor straight to choosing a vendor", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<NotificationSessionProvider>
|
||||
<NotificationsPage />
|
||||
<LocationProbe />
|
||||
</NotificationSessionProvider>,
|
||||
{ withAuth: false, route: "/notifications" },
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("region", { name: "No Vendor, 1" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Notifications" })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: new RegExp(noVendor.title) }));
|
||||
|
||||
expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent(
|
||||
"/workorders/edit/7?assignVendor=1",
|
||||
);
|
||||
});
|
||||
|
||||
it("links every work order in a vendor conflict", async () => {
|
||||
apiGet.mockResolvedValue(
|
||||
feed(
|
||||
section("vendor-conflict", "Vendor Conflict", [
|
||||
item({
|
||||
id: "vendor-conflict-501-502",
|
||||
reason: "vendor-conflict",
|
||||
severity: "Medium",
|
||||
title: "Acme Plumbing has overlapping appointments",
|
||||
count: 2,
|
||||
target: { kind: "workOrder", workOrderId: 501, tab: "info" },
|
||||
workOrders: [
|
||||
{ id: 501, number: "501" },
|
||||
{ id: 502, number: "502" },
|
||||
],
|
||||
}),
|
||||
]),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<NotificationSessionProvider>
|
||||
<NotificationsPage />
|
||||
<LocationProbe />
|
||||
</NotificationSessionProvider>,
|
||||
{ withAuth: false, route: "/notifications" },
|
||||
);
|
||||
|
||||
const conflicts = await screen.findByRole("region", { name: "Vendor Conflict, 2" });
|
||||
expect(within(conflicts).getByRole("link", { name: "WO #501" })).toHaveAttribute(
|
||||
"href",
|
||||
"/workorders/501",
|
||||
);
|
||||
|
||||
await user.click(within(conflicts).getByRole("link", { name: "WO #502" }));
|
||||
|
||||
expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent(
|
||||
"/workorders/502",
|
||||
);
|
||||
const row = within(conflicts).getByRole("listitem");
|
||||
expect(row.dataset.unread).toBe("false");
|
||||
});
|
||||
|
||||
it("tells the user when the feed cannot load", async () => {
|
||||
apiGet.mockRejectedValue(new Error("boom"));
|
||||
renderWithProviders(
|
||||
<NotificationSessionProvider>
|
||||
<NotificationsPage />
|
||||
</NotificationSessionProvider>,
|
||||
{ withAuth: false, route: "/notifications" },
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText("Notifications could not be loaded. They will retry automatically."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -51,9 +51,12 @@ describe("work-order drilldown links", () => {
|
|||
});
|
||||
|
||||
it("renders plain or query-bearing work order URLs", () => {
|
||||
expect(workOrderDrilldownUrl(new URLSearchParams())).toBe("/workorders");
|
||||
expect(workOrderDrilldownUrl(new URLSearchParams())).toBe("/workorders?fromDashboard=1");
|
||||
expect(workOrderDrilldownUrl(new URLSearchParams({ statuses: "Scheduled" }))).toBe(
|
||||
"/workorders?statuses=Scheduled",
|
||||
"/workorders?statuses=Scheduled&fromDashboard=1",
|
||||
);
|
||||
expect(
|
||||
workOrderDrilldownUrl(new URLSearchParams({ statuses: "Scheduled" }), "notification"),
|
||||
).toBe("/workorders?statuses=Scheduled");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,4 +42,53 @@ describe("upliftsApi", () => {
|
|||
|
||||
await expect(upliftsApi.canApprove(2)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
describe("openEvidence", () => {
|
||||
function evidenceResponse(body: string, contentType: string): Response {
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": 'attachment; filename="quote.pdf"',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function fakeTab() {
|
||||
return { location: { href: "about:blank" }, close: vi.fn() };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
URL.createObjectURL = vi.fn(() => "blob:evidence");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
it("renders an inert evidence type in the tab opened by the click", async () => {
|
||||
apiRequestRaw.mockResolvedValueOnce(evidenceResponse("%PDF", "application/pdf"));
|
||||
const tab = fakeTab();
|
||||
|
||||
await upliftsApi.openEvidence(7, tab as unknown as Window);
|
||||
|
||||
expect(apiRequestRaw).toHaveBeenCalledWith(
|
||||
"get",
|
||||
"uplifts/7/evidence",
|
||||
undefined,
|
||||
"upliftsApi.openEvidence",
|
||||
);
|
||||
expect(tab.location.href).toBe("blob:evidence");
|
||||
expect(tab.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("downloads a type the browser could execute instead of rendering it on the app origin", async () => {
|
||||
apiRequestRaw.mockResolvedValueOnce(evidenceResponse("<svg/>", "image/svg+xml"));
|
||||
const tab = fakeTab();
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
|
||||
await upliftsApi.openEvidence(8, tab as unknown as Window);
|
||||
|
||||
expect(tab.location.href).toBe("about:blank");
|
||||
expect(tab.close).toHaveBeenCalled();
|
||||
expect(click).toHaveBeenCalled();
|
||||
click.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -108,9 +108,25 @@ describe("mapUpliftQueueItem", () => {
|
|||
expect(result.workOrderClosed).toBe(false);
|
||||
});
|
||||
|
||||
it("maps the detail modal work-order context from the queue contract", () => {
|
||||
const result = mapUpliftQueueItem({
|
||||
id: 4,
|
||||
TechnicianName: "Tom Tech",
|
||||
WorkOrderDispatcherName: "Dana Ruiz",
|
||||
WorkOrderScheduledDate: "2026-04-10T09:30:00",
|
||||
});
|
||||
|
||||
expect(result.technicianName).toBe("Tom Tech");
|
||||
expect(result.workOrderDispatcherName).toBe("Dana Ruiz");
|
||||
expect(result.workOrderScheduledDate).toBe("2026-04-10T09:30:00");
|
||||
});
|
||||
|
||||
it("tolerates absent optional queue fields", () => {
|
||||
const result = mapUpliftQueueItem({ id: 3 });
|
||||
|
||||
expect(result.technicianName).toBe("");
|
||||
expect(result.workOrderDispatcherName).toBe("");
|
||||
expect(result.workOrderScheduledDate).toBe("");
|
||||
expect(result.woNumber).toBe("");
|
||||
expect(result.site).toBe("");
|
||||
expect(result.serviceName).toBe("");
|
||||
|
|
|
|||
|
|
@ -658,6 +658,100 @@ describe("workOrdersApi.patchBoardField", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.updatePoc", () => {
|
||||
beforeEach(() => {
|
||||
apiPatchFn.mockReset();
|
||||
});
|
||||
|
||||
it("PATCHes poc payload to workorders/{id}/poc and maps row", async () => {
|
||||
apiPatchFn.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 10,
|
||||
siteCode: "BK5",
|
||||
pocName: "Manual POC",
|
||||
pocPhone: "312-555-0001",
|
||||
pocNotes: "Call first",
|
||||
lifecycleStatusLabel: "Scheduled",
|
||||
rowVersion: "AAAAAAAB",
|
||||
dispatchRowVersion: null,
|
||||
primaryDispatchId: null,
|
||||
vendorId: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const row = await workOrdersApi.updatePoc(10, {
|
||||
pocName: "Manual POC",
|
||||
pocPhone: "312-555-0001",
|
||||
pocNotes: "Call first",
|
||||
workOrderVersion: "AAAAAAA=",
|
||||
});
|
||||
|
||||
expect(apiPatchFn).toHaveBeenCalledWith(API_PATHS.workOrder.pocPatch(10), {
|
||||
json: {
|
||||
pocName: "Manual POC",
|
||||
pocPhone: "312-555-0001",
|
||||
pocNotes: "Call first",
|
||||
workOrderVersion: "AAAAAAA=",
|
||||
},
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
expect(row.pocName).toBe("Manual POC");
|
||||
expect(row.rowVersion).toBe("AAAAAAAB");
|
||||
});
|
||||
|
||||
it("throws BoardPatchConflictError with currentState on 409", async () => {
|
||||
apiPatchFn.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({
|
||||
message: "The record was modified by another user. Refresh and retry.",
|
||||
currentState: {
|
||||
id: 10,
|
||||
siteCode: "BK5",
|
||||
rowVersion: "NEWVER==",
|
||||
lifecycleStatusLabel: "Scheduled",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const { BoardPatchConflictError } =
|
||||
await import("@/domain/work-orders/errors/board-patch-errors");
|
||||
|
||||
await expect(
|
||||
workOrdersApi.updatePoc(10, {
|
||||
pocName: "Manual POC",
|
||||
pocPhone: "312-555-0001",
|
||||
pocNotes: "",
|
||||
workOrderVersion: "STALE==",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BoardPatchConflictError);
|
||||
});
|
||||
|
||||
it("throws BoardPatchValidationError on 422 ReadOnly", async () => {
|
||||
apiPatchFn.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 422,
|
||||
json: async () => ({
|
||||
code: "ReadOnly",
|
||||
message: "Work order is read-only in its current status.",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
workOrdersApi.updatePoc(10, {
|
||||
pocName: "Manual POC",
|
||||
pocPhone: "312-555-0001",
|
||||
pocNotes: "",
|
||||
workOrderVersion: "AAAAAAA=",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "BoardPatchValidationError",
|
||||
code: "ReadOnly",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.createComment", () => {
|
||||
beforeEach(() => {
|
||||
apiPost.mockReset();
|
||||
|
|
|
|||
|
|
@ -34,6 +34,12 @@ describe("isLocalOnlyTablePatch", () => {
|
|||
it("treats scheduleEnd and schedMode as local-only overlays", () => {
|
||||
expect(isLocalOnlyTablePatch({ scheduleEnd: "2026-06-12", schedMode: "range" })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat POC edits as local-only", () => {
|
||||
expect(isLocalOnlyTablePatch({ pocName: "Pat", pocPhone: "111", pocNotes: "call" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandTablePatchToBoardFieldOps", () => {
|
||||
|
|
@ -185,7 +191,33 @@ describe("expandTablePatchToBoardFieldOps", () => {
|
|||
pocPhone: "111",
|
||||
pocNotes: "call",
|
||||
}),
|
||||
).toEqual([{ field: "siteCode", value: "BK5" }]);
|
||||
).toEqual([
|
||||
{
|
||||
kind: "poc",
|
||||
pocName: "Pat",
|
||||
pocPhone: "111",
|
||||
pocNotes: "call",
|
||||
},
|
||||
{ field: "siteCode", value: "BK5" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits one composite POC op before scalar ops", () => {
|
||||
expect(
|
||||
expandTablePatchToBoardFieldOps({
|
||||
pocName: "Pat",
|
||||
pocNotes: "call first",
|
||||
docStatus: "Yes",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "poc",
|
||||
pocName: "Pat",
|
||||
pocPhone: undefined,
|
||||
pocNotes: "call first",
|
||||
},
|
||||
{ field: "docStatus", value: "Yes" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps dueDate and scheduledDate patches independent", () => {
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@ import type {
|
|||
|
||||
const getById = vi.fn();
|
||||
const update = vi.fn();
|
||||
const updatePoc = vi.fn();
|
||||
const patchBoardField = vi.fn();
|
||||
|
||||
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
|
||||
workOrdersApi: {
|
||||
getById: (...args: unknown[]) => getById(...args),
|
||||
update: (...args: unknown[]) => update(...args),
|
||||
updatePoc: (...args: unknown[]) => updatePoc(...args),
|
||||
patchBoardField: (...args: unknown[]) => patchBoardField(...args),
|
||||
},
|
||||
}));
|
||||
|
|
@ -121,6 +123,7 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
|||
beforeEach(() => {
|
||||
getById.mockReset();
|
||||
update.mockReset();
|
||||
updatePoc.mockReset();
|
||||
patchBoardField.mockReset();
|
||||
serverState = { ...BASE_SERVER };
|
||||
rowState = { ...BASE_ROW };
|
||||
|
|
@ -829,6 +832,40 @@ describe("useWorkOrderTableMutations type and severity", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("useWorkOrderTableMutations POC persistence", () => {
|
||||
it("fills omitted POC fields from the current row before the full replacement", async () => {
|
||||
const currentRow = {
|
||||
...BASE_ROW,
|
||||
pocName: "Alex",
|
||||
pocPhone: "555-0000",
|
||||
pocNotes: "Gate",
|
||||
};
|
||||
updatePoc.mockResolvedValue({ ...currentRow, pocName: "Jordan", rowVersion: "wo-v2" });
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch: vi.fn(),
|
||||
clearPatch: () => undefined,
|
||||
getRow: () => currentRow,
|
||||
}),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(42, { pocName: "Jordan" });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(updatePoc).toHaveBeenCalled());
|
||||
expect(updatePoc).toHaveBeenCalledWith(42, {
|
||||
pocName: "Jordan",
|
||||
pocPhone: "555-0000",
|
||||
pocNotes: "Gate",
|
||||
workOrderVersion: "AAAAAAA=",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkOrderTableMutations audit cache refresh", () => {
|
||||
it("invalidates work-order queries after a successful status transition", async () => {
|
||||
const client = new QueryClient({
|
||||
|
|
|
|||
|
|
@ -67,7 +67,9 @@ describe("slide-over-draft", () => {
|
|||
expect(SLIDE_OVER_EDIT_KEYS).toContain("vendorId");
|
||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("additionalContacts");
|
||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("avetaRequired");
|
||||
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("pocName");
|
||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("pocName");
|
||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("pocPhone");
|
||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("pocNotes");
|
||||
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId");
|
||||
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("techPhone");
|
||||
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("company");
|
||||
|
|
@ -85,11 +87,11 @@ describe("slide-over-draft", () => {
|
|||
expect(buildSlideOverPatch(vendorDraft, row)).toEqual({ vendorId: "v2" });
|
||||
});
|
||||
|
||||
it("ignores POC-only edits for dirty state", () => {
|
||||
it("tracks POC edits for dirty state and patch", () => {
|
||||
const row = baseRow();
|
||||
const draft = baseRow({ pocName: "Jordan", pocPhone: "555-0100" });
|
||||
expect(isSlideOverDraftDirty(draft, row)).toBe(false);
|
||||
expect(buildSlideOverPatch(draft, row)).toEqual({});
|
||||
expect(isSlideOverDraftDirty(draft, row)).toBe(true);
|
||||
expect(buildSlideOverPatch(draft, row)).toEqual({ pocName: "Jordan", pocPhone: "555-0100" });
|
||||
});
|
||||
|
||||
it("detects dirty draft when a tracked field changes", () => {
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
## Jira
|
||||
|
||||
- [SH-192](https://luby-us.atlassian.net/browse/SH-192) — severity on Emergency/Reactive
|
||||
- Related: [SH-107](https://luby-us.atlassian.net/browse/SH-107) (WO# provisional SH…), [SH-108](https://luby-us.atlassian.net/browse/SH-108) (POC)
|
||||
|
||||
## QA residual
|
||||
|
||||
- P0 wizard fixed: Type optional (default PM); WO# alphanumeric / SH provisional (not digits-only).
|
||||
- P1: COL_HELP tooltips, Scheduled-requires-date — follow-up.
|
||||
|
||||
## Summary
|
||||
|
||||
- Add SEV 1–5 domain field on board row/create/patch and Type cell modal for Emergency/Reactive.
|
||||
- Wizard gates: required WO# (alphanumeric/SH ok), Type optional (defaults PM), severity when E/R, POC name/phone on step 2 with error UX.
|
||||
|
||||
## Scope
|
||||
|
||||
### In
|
||||
|
||||
- `severity` on WorkOrderBoardRowApi / create payload / editable field / table row / wizard draft
|
||||
- Type cell popover + TypeSeverityModal + SeverityBadge
|
||||
- Wizard step1/step2 validation + footer attempt-continue errors
|
||||
- Unit tests for canContinue* and severity patch mapping
|
||||
|
||||
### Out (follow-up PRs)
|
||||
|
||||
- Advanced filter severity facet (PR5)
|
||||
- Uplift UI, Completed Date column, multi-POC, SH auto-numbering beyond wizard gate
|
||||
|
||||
## API / BE
|
||||
|
||||
- Contract assumed: `severity` on GET board/search row, POST create, and PATCH field `severity`
|
||||
- Blocked on BE: none for FE wiring; 422 if severity missing for E/R should be handled by BE
|
||||
|
||||
## Test plan
|
||||
|
||||
- [ ] Board: pick Emergency/Reactive → severity modal required; badge shows SEV
|
||||
- [ ] Wizard: cannot continue step1 without WO#; E/R without severity; step2 without POC
|
||||
- [ ] `npm run verify` green locally
|
||||
|
||||
## Base
|
||||
|
||||
- Base branch: `dev`
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
# PR4 — Pending uplift close gate (+ SH-114 closability union)
|
||||
|
||||
## Summary
|
||||
|
||||
Blocks marking a work order **Completed** (and future completion-document generation) when closability gaps remain — **SH-114 field gates ∪ pending uplift** (SH-188).
|
||||
|
||||
- Unifies `wo-closability.ts` / `getClosabilityGaps` with hierarchy: **fields → uplift pending → completion document / photos**.
|
||||
- Field gates: dispatcher, technician, completed date, real WO# (non-SH), service; then PM/Overdue completion doc; Emergency/Reactive ≥1 photo.
|
||||
- Pending uplift via `hasPendingUplift` or `upliftSummary.pendingCount`.
|
||||
- Completed tooltip uses design copy when the **only** gap is pending uplift.
|
||||
- Maps optional `completedDate` on board rows for the Completed Date gate.
|
||||
|
||||
## Jira
|
||||
|
||||
- [SH-188](https://luby-us.atlassian.net/browse/SH-188) — pending uplift close gate
|
||||
- Related: [SH-114](https://luby-us.atlassian.net/browse/SH-114) — closability field matrix (pulled into this branch so sister merges do not wipe gates)
|
||||
|
||||
## Comp Doc Generate
|
||||
|
||||
`CompDocDialog` is **not** mounted on `dev` yet. This PR exports `canGenerateCompDoc(row)` so the completion-doc slice can block generate/open with the same reason string. No UI wiring in this PR.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Full uplift CRUD (PR3)
|
||||
- Advanced filter facets for uplift
|
||||
- Scheduled-requires-date gate (design P1)
|
||||
- COL_HELP tooltips (P1)
|
||||
|
||||
## Test plan
|
||||
|
||||
- [ ] Row with all fields ok + `hasPendingUplift: true` — Completed disabled; design tooltip about pending uplift.
|
||||
- [ ] Row missing dispatcher / completed date — Completed disabled; `Missing: …` tooltip.
|
||||
- [ ] Row fully closable — Completed selectable.
|
||||
- [ ] `canGenerateCompDoc` blocked when gaps exist.
|
||||
- [ ] Board API `completedDate` / `upliftSummary.pendingCount` map through to the gate.
|
||||
|
||||
## Base
|
||||
|
||||
Branch `feature/wo-uplift-pending-close-gate` from local `dev`. Merge **before** or instead of stacking raw SH-114 alone with an uplift-only closability overwrite.
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
## Jira
|
||||
|
||||
- [SH-193](https://luby-us.atlassian.net/browse/SH-193)
|
||||
|
||||
## QA residual
|
||||
|
||||
- P0 done: technician is staged as a **local draft** until the vendor dialog is saved / wizard submitted — Cancel does not orphan a vendor row.
|
||||
|
||||
## Summary
|
||||
|
||||
- Adds **+ Add technician** to the WO board vendor dialog when a company context is known (from the row or the current selection).
|
||||
- Stages a draft technician locally (`draft:` id) without calling `POST vendors` until the dialog save commits the selection.
|
||||
- On save, create runs via the existing **`POST vendors`** REST endpoint (`vendorsApi.create` / `useCreateVendor`) using the company profile + new `contactName`.
|
||||
|
||||
## Backend
|
||||
|
||||
Uses the established vendor create API (`API_PATHS.rest.vendors` → `POST /vendors`). Each vendor record represents one technician; linking to an existing company is done by sending the same `companyName` (and optional `companyId` when available). **No new endpoint**.
|
||||
|
||||
## Test plan
|
||||
|
||||
- [ ] Open vendor dialog → **+ Add technician** → fill name → Cancel dialog → no vendor create API call.
|
||||
- [ ] Add technician → Save dialog → `POST /vendors` once; row shows new tech.
|
||||
- [ ] `npm run verify`
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Multi-POC, multi-tech arrays on WO, vendors directory redesign.
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
# PR9 — Work order comment @mentions
|
||||
|
||||
## Jira
|
||||
|
||||
- [SH-217](https://luby-us.atlassian.net/browse/SH-217) — FE @mention autocomplete / chips
|
||||
- Related BE: [SH-75](https://luby-us.atlassian.net/browse/SH-75)
|
||||
|
||||
## QA residual
|
||||
|
||||
- Parcial / Blocked-BE for notification + structured `mentions[]` confirm; token chip polish = P1.
|
||||
|
||||
## Summary
|
||||
|
||||
- Adds `@` autocomplete to the internal comments composer on work order detail (`/workorders/:id`).
|
||||
- Encodes selected users as `@[userId:Display Name]` tokens in comment text so mentions stay readable when the backend ignores the optional payload field.
|
||||
- Sends `mentions: string[]` on `POST workorders/{id}/comments` when the composer contains encoded tokens.
|
||||
- Highlights mentions in the comment thread using encoded tokens and/or `mentions` returned by the API.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Notification Center deep-linking for mention events.
|
||||
- Backend notification triggers beyond the FE payload.
|
||||
|
||||
## BE contract
|
||||
|
||||
See `docs/work-orders/pending-be-contract.md` (Comments section):
|
||||
|
||||
| Field | Status |
|
||||
| ---------------------------------------- | -------- |
|
||||
| POST `{ text, mentions?: string[] }` | FE ready |
|
||||
| Response includes mentions for highlight | FE ready |
|
||||
|
||||
Until BE confirms, encoded tokens in `text` preserve highlight behavior client-side.
|
||||
|
||||
## Test plan
|
||||
|
||||
- [ ] Open a work order detail → Internal tab → type `@` and pick a dispatcher from the list.
|
||||
- [ ] Submit comment; verify POST body includes `text` with encoded token and `mentions` array.
|
||||
- [ ] Reload detail; verify mention is highlighted in the thread (token and/or API `mentions`).
|
||||
- [ ] Type `@` mid-word (e.g. email) — autocomplete should not open.
|
||||
- [ ] Run `npm run verify`.
|
||||