Compare commits

..

1 commit

Author SHA1 Message Date
renovate[bot]
0213a2ac18
fix(deps): update npm minor and patch 2026-09-25 02:18:24 +00:00
91 changed files with 291 additions and 3125 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 123 KiB

View file

@ -191,11 +191,7 @@ async function mockDashboardApi(page: Page, userRoles: string) {
return;
}
if (pathname.includes("/Dashboard/Stats")) {
// The server reports no unassigned work for a dispatcher-scoped view.
const unassigned = userRoles === "Dispatcher" ? 0 : 11;
return fulfillJson(route, { data: { ...STATS, unassigned } });
}
if (pathname.includes("/Dashboard/Stats")) return fulfillJson(route, { data: STATS });
if (pathname.includes("/Dashboard/Workload")) return fulfillJson(route, { data: WORKLOAD });
if (pathname.includes("/Dashboard/Performance"))
return fulfillJson(route, { data: PERFORMANCE });

View file

@ -97,7 +97,7 @@ test("vendor uploads scanned evidence and submits a dispatch-scoped uplift", asy
await page.getByLabel("New total NTE").fill("125");
await page.getByLabel("Reason").fill("Replacement compressor");
await page.getByLabel("Evidence (PDF, JPG, PNG, HEIC, MP4, MOV)").setInputFiles({
await page.getByLabel("Evidence (PDF/JPG/PNG)").setInputFiles({
name: "estimate.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4\nmock estimate"),
@ -349,7 +349,7 @@ test("internal approver can approve, reject, and revoke with audited notes", asy
await expect(page.getByRole("heading", { name: "Uplift Approvals" })).toBeVisible();
const approvalRow = page.getByRole("row").filter({ hasText: "DSP-41" });
await approvalRow.getByRole("button", { name: "Approve $25.00" }).click();
await approvalRow.getByRole("button", { name: "Approve" }).click();
const changesRow = page.getByRole("row").filter({ hasText: "DSP-42" });
await changesRow.getByRole("button", { name: "Reject" }).click();
@ -360,7 +360,7 @@ test("internal approver can approve, reject, and revoke with audited notes", asy
const approvedRow = page.getByRole("row").filter({ hasText: "DSP-41" });
await approvedRow.getByRole("button", { name: "Revoke" }).click();
await page.getByLabel("Reason for revoking").fill("Approval was made in error.");
await page.getByRole("button", { name: "Revoke $25.00" }).click();
await page.getByRole("button", { name: "Revoke $125" }).click();
await expect
.poll(() => decisions)

70
package-lock.json generated
View file

@ -21,7 +21,7 @@
"@hookform/resolvers": "^5.9.1",
"@mui/icons-material": "9.4.0",
"@mui/material": "9.4.0",
"@sentry/react": "10.75.2",
"@sentry/react": "10.75.1",
"@tanstack/query-broadcast-client-experimental": "5.103.1",
"@tanstack/react-query": "5.103.1",
"@tanstack/react-query-devtools": "5.103.1",
@ -2157,30 +2157,30 @@
"license": "MIT"
},
"node_modules/@sentry/browser": {
"version": "10.75.2",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.75.2.tgz",
"integrity": "sha512-2Q8Bd6aTIHmqWL/rGDZdr1JTGW2fYPXf+lMyhHp4CX6OE7gMaaI5ZUwQWgVelqUOvKkOZHHk0wo6K8+ExdwawQ==",
"version": "10.75.1",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.75.1.tgz",
"integrity": "sha512-e5BdZSgATDHt6Vg4bK5F/M2onf2rP6GqMt/sxKHhbzmNyrZ3t+8J4XJp5LO5fyo/ztIm/6GVPp3Xq3D1RudkyQ==",
"license": "MIT",
"dependencies": {
"@sentry/browser-utils": "10.75.2",
"@sentry/browser-utils": "10.75.1",
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.75.2",
"@sentry/feedback": "10.75.2",
"@sentry/replay": "10.75.2",
"@sentry/replay-canvas": "10.75.2"
"@sentry/core": "10.75.1",
"@sentry/feedback": "10.75.1",
"@sentry/replay": "10.75.1",
"@sentry/replay-canvas": "10.75.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser-utils": {
"version": "10.75.2",
"resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.75.2.tgz",
"integrity": "sha512-U9RIJE0vZHWEnfEpJAa/SQ60MsNSUaxww4MVymNLwJ26BrLFz9jLhVIW9bXcx0i1iwK+KQbIv6ebPDOnCfYUPQ==",
"version": "10.75.1",
"resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.75.1.tgz",
"integrity": "sha512-rU2sE3Jd5fNlMoA9ZLwEdaOvCqknV1vafDI1bJtr38UuFQxOxp/+cKREZzBhvfycwxc6aEFebDrNslDLJl4ZUw==",
"license": "MIT",
"dependencies": {
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.75.2"
"@sentry/core": "10.75.1"
},
"engines": {
"node": ">=18"
@ -2379,9 +2379,9 @@
}
},
"node_modules/@sentry/core": {
"version": "10.75.2",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.2.tgz",
"integrity": "sha512-KXzNf2c430cJIOMv84+MKpkXgave1DBNMNNwn073olVD773NME5IhDv/9VQyiFM2wIU+6bm4FEBVEVSuzqgjJw==",
"version": "10.75.1",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.1.tgz",
"integrity": "sha512-+/+UanewqPK+oJvDQIHWKLUqnsa0iymEThOPCaFBA8ulbQh9k8lsz8V+NtJwP4G3ncclfzp15tzqVwn6iugV4Q==",
"license": "MIT",
"dependencies": {
"@sentry/conventions": "^0.16.0"
@ -2391,26 +2391,26 @@
}
},
"node_modules/@sentry/feedback": {
"version": "10.75.2",
"resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.75.2.tgz",
"integrity": "sha512-/nrtMVSxOqBAigMWxIQS+Y8OqQ27ty+isMh6+X96kfAp/SaGrNZ425SZxKwq0mwHS0WnVkF+rE5dJnbbi56XSA==",
"version": "10.75.1",
"resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.75.1.tgz",
"integrity": "sha512-2P5G160I0bHW4jXTU5gJB4I6h/mamAdkK2oezyuE6+bwoXZ2JSh7+hug/FLhzIQMu92ZoyrP6ar/1v6TC33w+w==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.75.2"
"@sentry/core": "10.75.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/react": {
"version": "10.75.2",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.75.2.tgz",
"integrity": "sha512-4MtB1tSmMHzwTwY7m30UzRbbUx9cP84UK7WIDL9g7RvoGd/aZwxbp6IkUJ46VBNItVoSF+1GM9wtqzkLma252g==",
"version": "10.75.1",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.75.1.tgz",
"integrity": "sha512-Cq9gE2OvWl7VCV+SeiN4AdutSjMJssleDyyGmGpGR7JdSpNm/G5Q1ADpb9/7EIP4hKL4ZKmnF7jliYBQLc7/7A==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "10.75.2",
"@sentry/browser": "10.75.1",
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.75.2"
"@sentry/core": "10.75.1"
},
"engines": {
"node": ">=18"
@ -2420,26 +2420,26 @@
}
},
"node_modules/@sentry/replay": {
"version": "10.75.2",
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.75.2.tgz",
"integrity": "sha512-cCfdPt892S+rrAFOuUvbwR318/ZPFfBFvVkMlL3goktAnbECAGy6comAxLQEalyp22Ee67bEqYQN7RdNuRHIJw==",
"version": "10.75.1",
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.75.1.tgz",
"integrity": "sha512-sO3tRGFw9rXPgQgSj9Q3IU6TxlVj4uSmVWuvOm6U0qoG0rZPQmRlBSWqgJN+BD1pNq6C/+nhryDsxD8TQ4dDkg==",
"license": "MIT",
"dependencies": {
"@sentry/browser-utils": "10.75.2",
"@sentry/core": "10.75.2"
"@sentry/browser-utils": "10.75.1",
"@sentry/core": "10.75.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/replay-canvas": {
"version": "10.75.2",
"resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.75.2.tgz",
"integrity": "sha512-4GVhCkn+/0QEpXdY8UfXahg8qjF2QTH1+qej/CLketHGfRqxLhdanzHU9ILesplWX5GeuTkPn5ZTmd5CJf+KXg==",
"version": "10.75.1",
"resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.75.1.tgz",
"integrity": "sha512-XvTWQl4ZcZyRA/xFZqJ/DO4hauZd3BsQEmaZZnR16OKc/UYK9BXLvcIiNgqLBf540uw/rzJmLvXzEKEohbUd9Q==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.75.2",
"@sentry/replay": "10.75.2"
"@sentry/core": "10.75.1",
"@sentry/replay": "10.75.1"
},
"engines": {
"node": ">=18"

View file

@ -61,7 +61,7 @@
"@hookform/resolvers": "^5.9.1",
"@mui/icons-material": "9.4.0",
"@mui/material": "9.4.0",
"@sentry/react": "10.75.2",
"@sentry/react": "10.75.1",
"@tanstack/query-broadcast-client-experimental": "5.103.1",
"@tanstack/react-query": "5.103.1",
"@tanstack/react-query-devtools": "5.103.1",

View file

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

View file

@ -1,71 +0,0 @@
import { useNavigate } from "react-router";
import AssignmentLateOutlinedIcon from "@mui/icons-material/AssignmentLateOutlined";
import EventOutlinedIcon from "@mui/icons-material/EventOutlined";
import PendingActionsIcon from "@mui/icons-material/PendingActions";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import { Box } from "@mui/material";
import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import type { DashboardStats } from "@/domain/dashboard/types/dashboard-stats";
import {
avetaPendingDrilldownSearch,
scheduledTomorrowDrilldownSearch,
unassignedDrilldownSearch,
UPLIFTS_ROUTE,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
interface DashboardKpiCardsProps {
stats?: DashboardStats;
loading: boolean;
range: DashboardRangeKey;
}
export function DashboardKpiCards({ stats, loading, range }: DashboardKpiCardsProps) {
const navigate = useNavigate();
const scheduledTomorrow = stats?.scheduledTomorrow ?? 0;
const pendingUplifts = stats?.pendingUplifts ?? 0;
const avetaPending = stats?.avetaPending ?? 0;
const unassigned = stats?.unassigned ?? 0;
return (
<Box className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<DashboardKpiCard
title="Scheduled Tomorrow"
icon={<EventOutlinedIcon />}
tone="primary"
value={stats?.scheduledTomorrow}
loading={loading}
active={scheduledTomorrow > 0}
onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))}
/>
<DashboardKpiCard
title="Pending Uplifts"
icon={<PendingActionsIcon />}
tone="info"
value={stats?.pendingUplifts}
loading={loading}
active={pendingUplifts > 0}
onSelect={() => navigate(UPLIFTS_ROUTE)}
/>
<DashboardKpiCard
title="Aveta Pending"
icon={<ShieldOutlinedIcon />}
tone="warning"
value={stats?.avetaPending}
loading={loading}
active={avetaPending > 0}
onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))}
/>
<DashboardKpiCard
title="Unassigned"
icon={<AssignmentLateOutlinedIcon />}
tone="warning"
value={stats?.unassigned}
loading={loading}
active={unassigned > 0}
onSelect={() => navigate(workOrderDrilldownUrl(unassignedDrilldownSearch(range)))}
/>
</Box>
);
}

View file

@ -1,7 +1,11 @@
import { useNavigate } from "react-router";
import EventOutlinedIcon from "@mui/icons-material/EventOutlined";
import PendingActionsIcon from "@mui/icons-material/PendingActions";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import { Box } from "@mui/material";
import { PageHeader } from "@/components/ui/page-header";
import { DashboardHeaderActions } from "@/app/(protected)/dashboard/_components/dashboard-header-actions";
import { DashboardKpiCards } from "@/app/(protected)/dashboard/_components/dashboard-kpi-cards";
import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card";
import { DashboardTrendCard } from "@/app/(protected)/dashboard/_components/dashboard-trend-card";
import { DispatcherTablesSection } from "@/app/(protected)/dashboard/_components/dispatcher-tables-section";
import { StatusDistributionCard } from "@/app/(protected)/dashboard/_components/status-distribution-card";
@ -15,8 +19,15 @@ import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-st
import { useDashboardVendorInsights } from "@/domain/dashboard/use-cases/use-dashboard-vendor-insights";
import { useDashboardWorkload } from "@/domain/dashboard/use-cases/use-dashboard-workload";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import {
avetaPendingDrilldownSearch,
scheduledTomorrowDrilldownSearch,
UPLIFTS_ROUTE,
workOrderDrilldownUrl,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
export default function DashboardPage() {
const navigate = useNavigate();
const scope = useDashboardScope();
const { canViewAll, range, dispatcherId, workloadPage, performancePage } = scope;
@ -51,7 +62,35 @@ export default function DashboardPage() {
/>
}
/>
<DashboardKpiCards stats={stats.data} loading={stats.isLoading} range={range} />
<Box className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<DashboardKpiCard
title="Scheduled Tomorrow"
icon={<EventOutlinedIcon />}
tone="primary"
value={stats.data?.scheduledTomorrow}
loading={stats.isLoading}
active={(stats.data?.scheduledTomorrow ?? 0) > 0}
onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))}
/>
<DashboardKpiCard
title="Pending Uplifts"
icon={<PendingActionsIcon />}
tone="info"
value={stats.data?.pendingUplifts}
loading={stats.isLoading}
active={(stats.data?.pendingUplifts ?? 0) > 0}
onSelect={() => navigate(UPLIFTS_ROUTE)}
/>
<DashboardKpiCard
title="Aveta Pending"
icon={<ShieldOutlinedIcon />}
tone="warning"
value={stats.data?.avetaPending}
loading={stats.isLoading}
active={(stats.data?.avetaPending ?? 0) > 0}
onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))}
/>
</Box>
<Box className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<WorkOrderBreakdownCard
breakdown={stats.data?.breakdown ?? []}

View file

@ -4,7 +4,3 @@ export function formatUpliftMoney(value: number): string {
maximumFractionDigits: 2,
})}`;
}
export function isGrantedAmount(value: number | null): value is number {
return value != null && value > 0;
}

View file

@ -12,13 +12,9 @@ import {
TableRow,
Tooltip,
} from "@mui/material";
import {
formatUpliftMoney,
isGrantedAmount,
} from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { Text } from "@/components/ui/text";
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
@ -104,7 +100,7 @@ function PendingRowActions({
disabled={!canDecide || isDecisionPending}
onClick={() => onApprove(row)}
>
Approve {formatUpliftMoney(upliftAmount(row))}
Approve
</Button>
</span>
</Tooltip>
@ -215,11 +211,11 @@ function UpliftApprovalRow({
</TableCell>
<TableCell>
<Text variant="body" className="font-medium">
{formatUpliftMoney(upliftAmount(row))}
{formatUpliftMoney(row.requestedNTE)}
</Text>
</TableCell>
<TableCell>
{isGrantedAmount(row.approvedOnWoTotal) ? (
{row.approvedOnWoTotal != null ? (
<Text variant="body">{formatUpliftMoney(row.approvedOnWoTotal)}</Text>
) : (
<Text variant="caption" tone="muted">

View file

@ -1,6 +1,5 @@
import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog";
import { RevokeDialog } from "@/app/(protected)/uplifts/_components/revoke-dialog";
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
import type { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
export type UpliftApprovalController = ReturnType<typeof useUpliftApprovalController>;
@ -24,7 +23,7 @@ export function UpliftDecisionDialogs({ controller }: { controller: UpliftApprov
<>
<RejectDialog
open={rejectTarget != null}
amount={rejectTarget ? upliftAmount(rejectTarget) : undefined}
amount={rejectTarget?.requestedNTE}
woNumber={woFallbackNumber(rejectTarget)}
onClose={closeReject}
onConfirm={handleRejectConfirm}
@ -32,7 +31,7 @@ export function UpliftDecisionDialogs({ controller }: { controller: UpliftApprov
/>
<RevokeDialog
open={revokeTarget != null}
amount={revokeTarget ? upliftAmount(revokeTarget) : undefined}
amount={revokeTarget?.requestedNTE}
woNumber={woFallbackNumber(revokeTarget)}
onClose={closeRevoke}
onConfirm={handleRevokeConfirm}

View file

@ -16,14 +16,10 @@ import {
formatUpliftCalendarDate,
formatUpliftDateTime,
} from "@/app/(protected)/uplifts/_components/format-uplift-dates";
import {
formatUpliftMoney,
isGrantedAmount,
} from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { Text } from "@/components/ui/text";
import { useWorkOrderUplifts } from "@/domain/work-orders/use-cases/use-work-order-uplifts";
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
@ -146,7 +142,6 @@ function ApprovedOnWoBreakdown({
breakdown: ExposureBreakdown;
unavailable: boolean;
}) {
const grantedTotal = isGrantedAmount(breakdown.total) ? breakdown.total : null;
return (
<Box sx={BORDERED_BLOCK_SX}>
<BreakdownRow
@ -161,12 +156,7 @@ function ApprovedOnWoBreakdown({
strong={false}
unavailable={unavailable}
/>
<BreakdownRow
label="Total"
value={grantedTotal}
strong
unavailable={unavailable && breakdown.total == null}
/>
<BreakdownRow label="Total" value={breakdown.total} strong unavailable={unavailable} />
</Box>
);
}
@ -282,7 +272,7 @@ function PendingModalActions({
disabled={!canDecide || approvePending}
onClick={() => onApprove(item)}
>
Approve {formatUpliftMoney(upliftAmount(item))}
Approve
</Button>
</span>
</Tooltip>
@ -395,7 +385,7 @@ function UpliftDetailContent({
Amount
</Text>
<Text variant="heading" as="span" tone="warning">
{formatUpliftMoney(upliftAmount(item))}
{formatUpliftMoney(item.requestedNTE)}
</Text>
</Box>
<WorkOrderSection item={item} />

View file

@ -5,12 +5,10 @@ import {
} from "@/app/(protected)/workorders/_components/media/media-uploader";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import type { WorkOrderMediaKindCounts } from "@/domain/work-orders/utils/work-order-media-limits";
type SlideOverCompletionMediaProps = {
isReadOnly: boolean;
media: MediaFile[];
mediaCounts?: WorkOrderMediaKindCounts;
mediaPending: boolean;
onUploadMedia?: (requests: MediaUploadRequest[]) => void;
onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void;
@ -22,7 +20,6 @@ type SlideOverCompletionMediaProps = {
export function SlideOverCompletionMedia({
isReadOnly,
media,
mediaCounts,
mediaPending,
onUploadMedia,
onCategorizeMedia,
@ -41,7 +38,6 @@ export function SlideOverCompletionMedia({
<div style={isReadOnly ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
<MediaUploader
uploads={media}
mediaCounts={mediaCounts}
onUpload={onUploadMedia}
onCategorize={onCategorizeMedia}
onRemove={onRemoveMedia}

View file

@ -12,7 +12,6 @@ import {
formatMediaSizeLimit,
isWorkOrderFileTooLarge,
MAX_WORK_ORDER_COMPLETION_DOC_BYTES,
type WorkOrderMediaKindCounts,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { SlideOverCompletionActions } from "./slide-over-completion-actions";
import { SlideOverCompletionMedia } from "./slide-over-completion-media";
@ -24,7 +23,6 @@ type SlideOverCompletionTabProps = {
isReadOnly: boolean;
completion?: WorkOrderBoardCompletion;
media: MediaFile[];
mediaCounts?: WorkOrderMediaKindCounts;
mediaPending?: boolean;
completionPending?: boolean;
onUploadMedia?: (requests: MediaUploadRequest[]) => void;
@ -41,7 +39,6 @@ export function SlideOverCompletionTab({
isReadOnly,
completion,
media,
mediaCounts,
mediaPending = false,
completionPending = false,
onUploadMedia,
@ -62,7 +59,6 @@ export function SlideOverCompletionTab({
<SlideOverCompletionMedia
isReadOnly={isReadOnly}
media={media}
mediaCounts={mediaCounts}
mediaPending={mediaPending}
onUploadMedia={onUploadMedia}
onCategorizeMedia={onCategorizeMedia}

View file

@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useRef } from "react";
import {
File as FileIcon,
FileText,
@ -18,16 +18,12 @@ import {
hasExplicitAvetaFile,
resolveExtraDocCategory,
} from "@/domain/work-orders/utils/extra-docs-aveta";
import {
screenWorkOrderUploads,
type WorkOrderUploadScreenResult,
} from "@/domain/work-orders/utils/screen-work-order-uploads";
import {
canDeleteWorkOrderMedia,
countWorkOrderMediaKinds,
formatMediaSizeLimit,
isWorkOrderExtraDocTypeAllowed,
isWorkOrderFileTooLarge,
workOrderExtraDocsAcceptAttribute,
type WorkOrderMediaKindCounts,
} from "@/domain/work-orders/utils/work-order-media-limits";
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
@ -57,16 +53,11 @@ type SlideOverExtrasTabProps = {
pending?: boolean;
avetaRequired?: boolean;
workOrderId?: string | number;
/** Photos/videos on the whole work order; Extra Docs shows only its own share. */
mediaCounts?: WorkOrderMediaKindCounts;
onUpload: (files: File[]) => void;
onRemove: (id: string) => void;
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
};
const UNSUPPORTED_TYPE_MESSAGE =
"Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV. Documents: PDF, DOC.";
async function openExtraDocContent(workOrderId: string | number, mediaId: string): Promise<void> {
const tab = window.open("about:blank", "_blank");
if (!tab) {
@ -185,38 +176,27 @@ export function SlideOverExtrasTab({
pending = false,
avetaRequired = false,
workOrderId,
mediaCounts,
onUpload,
onRemove,
onCategorize,
}: SlideOverExtrasTabProps) {
const inputRef = useRef<HTMLInputElement>(null);
const avetaAttached = hasExplicitAvetaFile(uploads);
// One selection at a time: a second pick during the async screen would read the
// same photo/video counts and could pass the per-work-order limit.
const screeningRef = useRef(false);
const [screening, setScreening] = useState(false);
const busy = pending || screening;
const addFiles = async (files: FileList | null) => {
if (isReadOnly || pending || screeningRef.current || !files || files.length === 0) return;
// Snapshot synchronously: the input is reset right after this handler returns.
const selected = Array.from(files);
screeningRef.current = true;
setScreening(true);
let screened: WorkOrderUploadScreenResult;
try {
screened = await screenWorkOrderUploads(selected, {
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
isTypeAllowed: isWorkOrderExtraDocTypeAllowed,
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
});
} finally {
screeningRef.current = false;
setScreening(false);
const addFiles = (files: FileList | null) => {
if (isReadOnly || pending || !files || files.length === 0) return;
const accepted: File[] = [];
for (const file of Array.from(files)) {
if (!isWorkOrderExtraDocTypeAllowed(file)) {
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV, PDF, DOC).`);
continue;
}
if (isWorkOrderFileTooLarge(file)) {
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
continue;
}
accepted.push(file);
}
const { accepted, rejections } = screened;
for (const message of rejections) toast.error(message);
if (accepted.length > 0) {
onUpload(accepted);
}
@ -248,12 +228,12 @@ export function SlideOverExtrasTab({
<>
<button
type="button"
disabled={busy}
disabled={pending}
onClick={() => inputRef.current?.click()}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
void addFiles(event.dataTransfer.files);
addFiles(event.dataTransfer.files);
}}
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-(--color-bg-muted) disabled:opacity-60"
style={{
@ -265,13 +245,13 @@ export function SlideOverExtrasTab({
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
<span
style={{ fontSize: 13, color: "var(--color-text-muted)" }}
aria-busy={busy || undefined}
aria-busy={pending || undefined}
>
{busy ? "Uploading…" : "Upload documents, photos or videos"}
{pending ? "Uploading…" : "Upload documents, photos or videos"}
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Accepted: JPG, PNG, HEIC, MP4, MOV, PDF, DOC · Photos up to 10 MB · Videos up to 100
MB and 90 s · Docs up to 50 MB
Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max {formatMediaSizeLimit().replace(" ", "")}{" "}
per file
</span>
</button>
<input
@ -280,9 +260,9 @@ export function SlideOverExtrasTab({
accept={workOrderExtraDocsAcceptAttribute()}
multiple
hidden
disabled={busy}
disabled={pending}
onChange={(event) => {
void addFiles(event.target.files);
addFiles(event.target.files);
event.target.value = "";
}}
/>

View file

@ -17,7 +17,6 @@ import {
isExtrasSurfaceMedia,
type MediaSurfaceOwnership,
} from "@/domain/work-orders/utils/partition-slide-over-media";
import { countWorkOrderMediaKinds } from "@/domain/work-orders/utils/work-order-media-limits";
type UseSlideOverMediaStateArgs = {
detailForRow?: WorkOrderBoardDetail;
@ -131,9 +130,6 @@ export function useSlideOverMediaState({
[detailForRow?.media, ownership, completionHasGallery],
);
// Per-work-order photo/video counts span both tabs, so count the whole session.
const mediaCounts = useMemo(() => countWorkOrderMediaKinds(uploads), [uploads]);
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
const mediaPending =
mediaBatchPending ||
@ -200,7 +196,6 @@ export function useSlideOverMediaState({
return {
completionMedia,
extrasUploads,
mediaCounts,
mediaPending,
uploadMediaRequests,
uploadExtraFiles,

View file

@ -118,7 +118,6 @@ export function useWorkOrderSlideOver({
const {
completionMedia,
extrasUploads,
mediaCounts,
mediaPending,
uploadMediaRequests,
uploadExtraFiles,
@ -175,7 +174,6 @@ export function useWorkOrderSlideOver({
...readFlags,
completionMedia,
extrasUploads,
mediaCounts,
mediaPending,
completionPending,
closeDisabled,

View file

@ -125,7 +125,6 @@ export function WorkOrderSlideOverTabs({
isReadOnly={state.isDocumentsReadOnly}
completion={state.detailForRow?.completion}
media={state.completionMedia}
mediaCounts={state.mediaCounts}
mediaPending={state.mediaPending}
completionPending={state.completionPending}
onUploadMedia={(requests) => {
@ -152,7 +151,6 @@ export function WorkOrderSlideOverTabs({
>
<SlideOverExtrasTab
uploads={state.extrasUploads}
mediaCounts={state.mediaCounts}
workOrderId={infoSource?.id}
avetaRequired={resolveSlideOverAvetaRequired(
state.editing,

View file

@ -27,12 +27,7 @@ export function AdvDateRangeSection({ draft, setDraft }: AdvDateRangeSectionProp
key={option.key}
label={option.label}
selected={draft.dateRange === option.key}
onClick={() =>
setDraft((prev) => ({
...prev,
dateRange: prev.dateRange === option.key ? null : option.key,
}))
}
onClick={() => setDraft((prev) => ({ ...prev, dateRange: option.key }))}
/>
))}
<AdvFilterPill

View file

@ -1,11 +1,7 @@
import type { RefObject } from "react";
import { Upload } from "lucide-react";
import {
MAX_PHOTOS_PER_WORK_ORDER,
MAX_VIDEOS_PER_WORK_ORDER,
MAX_VIDEO_DURATION_SECONDS,
MAX_WORK_ORDER_PHOTO_BYTES,
MAX_WORK_ORDER_VIDEO_BYTES,
formatMediaSizeLimit,
workOrderMediaAcceptAttribute,
} from "@/domain/work-orders/utils/work-order-media-limits";
@ -16,12 +12,6 @@ type MediaUploaderDropzoneProps = {
onFilesSelected: (files: FileList | null) => void;
};
function mediaContractHint(): string {
const photoLimit = Math.round(MAX_WORK_ORDER_PHOTO_BYTES / 1_000_000);
const videoLimit = Math.round(MAX_WORK_ORDER_VIDEO_BYTES / 1_000_000);
return `Photos up to ${photoLimit} MB (max ${MAX_PHOTOS_PER_WORK_ORDER}) · Videos up to ${videoLimit} MB and ${MAX_VIDEO_DURATION_SECONDS} s (max ${MAX_VIDEOS_PER_WORK_ORDER})`;
}
export function MediaUploaderDropzone({
inputRef,
pending,
@ -51,7 +41,7 @@ export function MediaUploaderDropzone({
{pending ? "Uploading…" : "Drag files here or click to browse"}
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Accepted: JPG, PNG, HEIC, MP4, MOV · {mediaContractHint()}
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit().replace(" ", "")} per file
{categorizeAfterUpload ? " · categorize after upload" : ""}
</span>
</button>

View file

@ -1,18 +1,14 @@
import { useRef, useState } from "react";
import { useRef } from "react";
import { toast } from "react-toastify";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import {
canDeleteWorkOrderMedia,
countWorkOrderMediaKinds,
formatMediaSizeLimit,
isWorkOrderFileTooLarge,
isWorkOrderMediaTypeAllowed,
type WorkOrderMediaKindCounts,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
import {
screenWorkOrderUploads,
type WorkOrderUploadScreenResult,
} from "@/domain/work-orders/utils/screen-work-order-uploads";
import { MediaUploaderDropzone } from "./media-uploader-dropzone";
import { MediaUploaderGrid } from "./media-uploader-grid";
@ -34,12 +30,8 @@ type MediaUploaderProps = {
onChange?: (next: MediaFile[]) => void;
readOnly?: boolean;
pending?: boolean;
/** Photos/videos on the whole work order when `uploads` is only one surface's share. */
mediaCounts?: WorkOrderMediaKindCounts;
};
const UNSUPPORTED_TYPE_MESSAGE = "Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV.";
export function MediaUploader({
uploads,
onUpload,
@ -50,35 +42,24 @@ export function MediaUploader({
onChange,
readOnly = false,
pending = false,
mediaCounts,
}: MediaUploaderProps) {
const inputRef = useRef<HTMLInputElement>(null);
// One selection at a time: a second pick during the async screen would read the
// same photo/video counts and could pass the per-work-order limit.
const screeningRef = useRef(false);
const [screening, setScreening] = useState(false);
const addFiles = async (files: FileList | null) => {
if (readOnly || pending || screeningRef.current || !files || files.length === 0) return;
const addFiles = (files: FileList | null) => {
if (readOnly || pending || !files || files.length === 0) return;
// Snapshot synchronously: the caller resets the input value right after this
// handler returns, which can detach a live FileList across awaits.
const selected = Array.from(files);
screeningRef.current = true;
setScreening(true);
let screened: WorkOrderUploadScreenResult;
try {
screened = await screenWorkOrderUploads(selected, {
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
isTypeAllowed: isWorkOrderMediaTypeAllowed,
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
});
} finally {
screeningRef.current = false;
setScreening(false);
const accepted: File[] = [];
for (const file of Array.from(files)) {
if (!isWorkOrderMediaTypeAllowed(file)) {
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
continue;
}
if (isWorkOrderFileTooLarge(file)) {
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
continue;
}
accepted.push(file);
}
const { accepted, rejections } = screened;
for (const message of rejections) toast.error(message);
if (accepted.length === 0) return;
if (onUpload) {
@ -112,9 +93,9 @@ export function MediaUploader({
{!readOnly && (
<MediaUploaderDropzone
inputRef={inputRef}
pending={pending || screening}
pending={pending}
categorizeAfterUpload={Boolean(onCategorize)}
onFilesSelected={(files) => void addFiles(files)}
onFilesSelected={addFiles}
/>
)}
<MediaUploaderGrid

View file

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

View file

@ -56,7 +56,6 @@ export function DispatchDetailSections({
token={token}
dispatchId={dispatchId}
documents={data.documents ?? []}
mediaCounts={data.mediaCounts}
locked={locked}
onChanged={refresh}
/>
@ -69,7 +68,6 @@ export function DispatchDetailSections({
dispatchId={dispatchId}
currentNte={data.nteAmount}
upliftRequests={data.upliftRequests ?? []}
mediaCounts={data.mediaCounts}
locked={locked}
onChanged={refresh}
pollDocument={pollEvidenceDocument}

View file

@ -1,7 +1,6 @@
import { useState } from "react";
import { Text } from "@/components/ui/text";
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
import type { VendorMediaUsage } from "@/domain/vendor-portal/lib/document-validation";
import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation";
import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal";
import { formatDateTime } from "@/lib/time-utils";
@ -16,7 +15,6 @@ type UpliftRequestCardProps = {
onWithdraw: (request: VendorPortalUpliftRequest) => void;
onRevise: (request: VendorPortalUpliftRequest, values: UpliftFormValues) => void;
onCancelRevise: () => void;
mediaUsage?: VendorMediaUsage;
};
const WITHDRAWABLE_STATUSES = new Set(["Pending", "ChangesRequested"]);
@ -25,21 +23,6 @@ function statusClassName(status: string): string {
return `vp-uplift-status-${String(status).toLowerCase()}`;
}
function availableActions(request: VendorPortalUpliftRequest, locked: boolean, canAct: boolean) {
// Requests raised from the work order are visible but read-only to the vendor.
const actionable = !locked && canAct && request.raisedByVendor !== false;
return {
canWithdraw: actionable && WITHDRAWABLE_STATUSES.has(request.status),
canRevise: actionable && request.status === "ChangesRequested",
};
}
// A vendor request stores the requested new NTE total; a work-order request stores the increase.
function requestedTotal(request: VendorPortalUpliftRequest): number {
const requested = Number(request.requestedNTE);
return request.raisedByVendor === false ? Number(request.currentNTE ?? 0) + requested : requested;
}
export function UpliftRequestCard({
request,
locked,
@ -50,17 +33,18 @@ export function UpliftRequestCard({
onWithdraw,
onRevise,
onCancelRevise,
mediaUsage,
}: UpliftRequestCardProps) {
const [revising, setRevising] = useState(false);
const { canWithdraw, canRevise } = availableActions(request, locked, canAct);
const canWithdraw = !locked && canAct && WITHDRAWABLE_STATUSES.has(request.status);
const canRevise = !locked && canAct && request.status === "ChangesRequested";
return (
<div className="vp-uplift-row">
<div className="vp-row">
<strong>Requested</strong>
<span>
${requestedTotal(request).toFixed(2)} (was ${Number(request.currentNTE ?? 0).toFixed(2)})
${Number(request.requestedNTE).toFixed(2)} (was $
{Number(request.currentNTE ?? 0).toFixed(2)})
</span>
</div>
<div className="vp-row">
@ -116,7 +100,6 @@ export function UpliftRequestCard({
initialRequested={String(request.requestedNTE)}
initialReason={request.vendorReason ?? ""}
hasExistingEvidence={request.evidenceDocumentId != null}
mediaUsage={mediaUsage}
submitting={submitting}
uploadingPercent={uploadingPercent}
errorMessage=""

View file

@ -1,10 +1,6 @@
import { useRef, useState } from "react";
import { useState } from "react";
import { Text } from "@/components/ui/text";
import {
validateVendorDocument,
VENDOR_UPLOAD_ACCEPT,
type VendorMediaUsage,
} from "@/domain/vendor-portal/lib/document-validation";
import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation";
import {
isApprovalRequired,
validateUpliftAmount,
@ -17,8 +13,6 @@ type UpliftRequestFormProps = {
initialRequested?: string;
initialReason?: string;
hasExistingEvidence?: boolean;
/** Work-order photo/video usage for the evidence pre-check. */
mediaUsage?: VendorMediaUsage;
submitting: boolean;
uploadingPercent: number | undefined;
errorMessage: string;
@ -32,7 +26,6 @@ export function UpliftRequestForm({
initialRequested = "",
initialReason = "",
hasExistingEvidence = false,
mediaUsage,
submitting,
uploadingPercent,
errorMessage,
@ -43,15 +36,11 @@ export function UpliftRequestForm({
const [reason, setReason] = useState(initialReason);
const [evidenceFile, setEvidenceFile] = useState<File | undefined>();
const [formError, setFormError] = useState("");
const [checkingEvidence, setCheckingEvidence] = useState(false);
const latestSelection = useRef(0);
const showApprovalFields = mode === "revise" || isApprovalRequired(requested, currentNte);
const submit = (event: React.FormEvent) => {
event.preventDefault();
// The picked file is not in state until its check settles; submitting now would validate the old one.
if (checkingEvidence) return;
const amount = Number.parseFloat(requested);
const validationError = validateUpliftAmount(requested, currentNte, reason, {
file: evidenceFile,
@ -65,15 +54,10 @@ export function UpliftRequestForm({
onSubmit({ amount, reason: reason.trim(), evidenceFile });
};
const chooseFile = async (file?: File) => {
const selection = ++latestSelection.current;
setCheckingEvidence(Boolean(file));
const validationMessage = file ? await validateVendorDocument(file, mediaUsage) : undefined;
// A newer pick may have settled while this one's video metadata was still loading.
if (selection !== latestSelection.current) return;
const chooseFile = (file?: File) => {
const validationMessage = file ? validateVendorDocument(file) : undefined;
setFormError(validationMessage ?? "");
setEvidenceFile(file);
setCheckingEvidence(false);
};
const shownError = errorMessage || formError;
@ -128,16 +112,14 @@ export function UpliftRequestForm({
</div>
<div className="vp-row">
<strong>
<label htmlFor={`uplift-evidence-${mode}`}>
Evidence (PDF, JPG, PNG, HEIC, MP4, MOV)
</label>
<label htmlFor={`uplift-evidence-${mode}`}>Evidence (PDF/JPG/PNG)</label>
</strong>
<input
id={`uplift-evidence-${mode}`}
type="file"
className="vp-input"
accept={VENDOR_UPLOAD_ACCEPT}
onChange={(event) => void chooseFile(event.target.files?.[0])}
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
onChange={(event) => chooseFile(event.target.files?.[0])}
/>
</div>
</>
@ -152,14 +134,8 @@ export function UpliftRequestForm({
{shownError}
</Text>
<div className="vp-actions">
<button type="submit" className="vp-btn vp-btn-primary" disabled={busy || checkingEvidence}>
{busy
? "Submitting…"
: checkingEvidence
? "Checking evidence…"
: mode === "revise"
? "Submit revision"
: "Request uplift"}
<button type="submit" className="vp-btn vp-btn-primary" disabled={busy}>
{busy ? "Submitting…" : mode === "revise" ? "Submit revision" : "Request uplift"}
</button>
{mode === "revise" && (
<button type="button" className="vp-btn vp-btn-ghost" onClick={onCancel} disabled={busy}>

View file

@ -4,12 +4,10 @@ import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import { createUpliftRequestKey } from "@/domain/vendor-portal/lib/request-key";
import type {
VendorPortalDocumentStatus,
VendorPortalMediaCounts,
VendorPortalUpliftRequest,
} from "@/domain/vendor-portal/types/vendor-portal";
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation";
import { vendorMediaUsage } from "@/domain/vendor-portal/lib/document-validation";
import { UpliftRequestCard } from "@/app/v/[token]/dispatch/_components/uplift-request-card";
import {
useVendorUpliftEvidence,
@ -26,7 +24,6 @@ type UpliftRequestsSectionProps = {
locked: boolean;
onChanged: () => Promise<void>;
pollDocument: (documentId: number) => Promise<VendorPortalDocumentStatus | undefined>;
mediaCounts?: VendorPortalMediaCounts | null;
};
type UseVendorUpliftActionsArgs = {
@ -77,7 +74,7 @@ function useVendorUpliftActions({
if (requiresApproval) {
if (!values.evidenceFile) {
setFormError(
"Attach PDF, JPG, PNG, HEIC, MP4, or MOV evidence — approval-required requests need evidence.",
"Attach PDF, JPG, or PNG evidence — approval-required requests need evidence.",
);
return;
}
@ -171,9 +168,7 @@ export function UpliftRequestsSection({
locked,
onChanged,
pollDocument,
mediaCounts,
}: UpliftRequestsSectionProps) {
const mediaUsage = vendorMediaUsage(mediaCounts, "evidence");
const evidence = useVendorUpliftEvidence({ pollDocument });
const {
submitting,
@ -210,7 +205,6 @@ export function UpliftRequestsSection({
onWithdraw={(r) => void handleWithdraw(r)}
onRevise={(r, values) => void handleRevise(r, values)}
onCancelRevise={evidence.reset}
mediaUsage={mediaUsage}
/>
))}
@ -218,7 +212,6 @@ export function UpliftRequestsSection({
<UpliftRequestForm
mode="create"
currentNte={currentNte}
mediaUsage={mediaUsage}
submitting={submitting}
uploadingPercent={evidence.uploadingPercent}
errorMessage={formError}

View file

@ -3,83 +3,17 @@ import { useMutation } from "@tanstack/react-query";
import { VendorPortalFeedback } from "@/app/v/_components/vendor-portal-feedback";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import {
validateVendorDocument,
VENDOR_UPLOAD_ACCEPT,
vendorMediaUsage,
} from "@/domain/vendor-portal/lib/document-validation";
import type {
VendorPortalDocument,
VendorPortalMediaCounts,
} from "@/domain/vendor-portal/types/vendor-portal";
import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation";
import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal";
type VendorPortalDocumentsProps = {
token: string;
dispatchId: number;
documents: VendorPortalDocument[];
locked: boolean;
mediaCounts?: VendorPortalMediaCounts | null;
onChanged: () => Promise<void>;
};
type VendorPortalDocumentRowProps = {
document: VendorPortalDocument;
locked: boolean;
uploading: boolean;
onDownload: () => void;
onReplace: () => void;
};
function VendorPortalDocumentRow({
document,
locked,
uploading,
onDownload,
onReplace,
}: VendorPortalDocumentRowProps) {
return (
<article className="vp-uplift-row">
<div className="vp-row">
<strong>File</strong>
<span>
{document.originalFileName} · v{document.version}
</span>
</div>
<div className="vp-row">
<strong>Security scan</strong>
<span>{document.scanStatus}</span>
</div>
<div className="vp-row">
<strong>Review</strong>
<span>{document.reviewStatus}</span>
</div>
{Boolean(document.rejectionReason) && (
<VendorPortalFeedback tone="error">{document.rejectionReason}</VendorPortalFeedback>
)}
<div className="vp-actions">
<button
type="button"
className="vp-btn vp-btn-ghost"
disabled={!document.canDownload}
onClick={onDownload}
>
{document.canDownload ? "Download" : "Quarantined"}
</button>
{!locked && (
<button
type="button"
className="vp-btn vp-btn-ghost"
disabled={uploading}
onClick={onReplace}
>
Replace
</button>
)}
</div>
</article>
);
}
type UploadRequest = {
file: File;
replacementId?: number;
@ -90,15 +24,12 @@ export function VendorPortalDocuments({
dispatchId,
documents,
locked,
mediaCounts,
onChanged,
}: VendorPortalDocumentsProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [replacementId, setReplacementId] = useState<number>();
const [progress, setProgress] = useState<number>();
const [validationError, setValidationError] = useState<string>();
// Bumped on every new pick so a slower validation of an earlier file cannot upload it.
const selectionRef = useRef(0);
const uploadMutation = useMutation({
mutationFn: ({ file, replacementId: requestedReplacementId }: UploadRequest) =>
@ -120,27 +51,18 @@ export function VendorPortalDocuments({
});
const chooseFile = (documentId?: number) => {
selectionRef.current += 1;
setValidationError(undefined);
uploadMutation.reset();
setReplacementId(documentId);
inputRef.current?.click();
};
const uploadSelectedFile = async (file?: File) => {
const uploadSelectedFile = (file?: File) => {
if (!file) {
return;
}
const selection = ++selectionRef.current;
const replaced = documents.find((document) => document.id === replacementId);
const validationMessage = await validateVendorDocument(
file,
vendorMediaUsage(mediaCounts, "completion", replaced?.contentType),
);
if (selection !== selectionRef.current) {
return;
}
const validationMessage = validateVendorDocument(file);
if (validationMessage) {
setValidationError(validationMessage);
return;
@ -161,9 +83,9 @@ export function VendorPortalDocuments({
ref={inputRef}
type="file"
aria-label="Choose completion document"
accept={VENDOR_UPLOAD_ACCEPT}
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
hidden
onChange={(event) => void uploadSelectedFile(event.target.files?.[0])}
onChange={(event) => uploadSelectedFile(event.target.files?.[0])}
/>
{documents.length === 0 && (
@ -171,14 +93,45 @@ export function VendorPortalDocuments({
)}
{documents.map((document) => (
<VendorPortalDocumentRow
key={document.id}
document={document}
locked={locked}
uploading={uploadMutation.isPending}
onDownload={() => void vendorPortalApi.downloadDocument(token, dispatchId, document)}
onReplace={() => chooseFile(document.id)}
/>
<article key={document.id} className="vp-uplift-row">
<div className="vp-row">
<strong>File</strong>
<span>
{document.originalFileName} · v{document.version}
</span>
</div>
<div className="vp-row">
<strong>Security scan</strong>
<span>{document.scanStatus}</span>
</div>
<div className="vp-row">
<strong>Review</strong>
<span>{document.reviewStatus}</span>
</div>
{Boolean(document.rejectionReason) && (
<VendorPortalFeedback tone="error">{document.rejectionReason}</VendorPortalFeedback>
)}
<div className="vp-actions">
<button
type="button"
className="vp-btn vp-btn-ghost"
disabled={!document.canDownload}
onClick={() => void vendorPortalApi.downloadDocument(token, dispatchId, document)}
>
{document.canDownload ? "Download" : "Quarantined"}
</button>
{!locked && (
<button
type="button"
className="vp-btn vp-btn-ghost"
disabled={uploadMutation.isPending}
onClick={() => chooseFile(document.id)}
>
Replace
</button>
)}
</div>
</article>
))}
{progress !== undefined && (
@ -202,10 +155,6 @@ export function VendorPortalDocuments({
>
Upload document
</button>
<span className="vp-hint">
Accepted: PDF, JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB · Videos up to 100 MB and
90 s · PDFs up to 10 MB
</span>
</div>
)}
</>

View file

@ -190,13 +190,6 @@
margin-top: 12px;
}
.vp-hint {
align-self: center;
flex-basis: 100%;
font-size: 12px;
color: var(--muted-foreground);
}
.vp-checklist-item {
display: flex;
align-items: center;

View file

@ -1,4 +1,3 @@
import { isDismissable } from "@/domain/notifications/utils/visible-notification-sections";
import type { NotificationCenter } from "./use-notification-center";
type NotificationFeedActionsProps = {
@ -7,8 +6,7 @@ type NotificationFeedActionsProps = {
export function NotificationFeedActions({ center }: NotificationFeedActionsProps) {
const hasUnread = center.unreadIds.size > 0;
// Rows that need acknowledging are never cleared, so they alone do not enable "Clear all".
const hasItems = center.sections.some((section) => section.items.some(isDismissable));
const hasItems = center.sections.length > 0;
return (
<>

View file

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

View file

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

View file

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

View file

@ -2,7 +2,6 @@ import { useMemo } from "react";
import { useNavigate } from "react-router";
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
import { useNotificationSession } from "@/domain/notifications/session/notification-session-context";
import { useAcknowledgeSlaBreach } from "@/domain/notifications/use-cases/use-acknowledge-sla-breach";
import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
import { notificationTargetUrl } from "@/domain/notifications/utils/notification-target-url";
import {
@ -27,13 +26,6 @@ export function useNotificationCenter(onNavigate?: () => void) {
[items, readIds],
);
const acknowledgeBreach = useAcknowledgeSlaBreach();
const acknowledge = (item: NotificationItem) => {
if (item.rowType !== "acknowledge" || item.target.workOrderId == null) return;
acknowledgeBreach.mutate(item.target.workOrderId);
};
const acknowledgingWorkOrderId = acknowledgeBreach.isPending ? acknowledgeBreach.variables : null;
const open = (item: NotificationItem, url = notificationTargetUrl(item, user?.id)) => {
markRead([item.id]);
if (url == null) return;
@ -49,8 +41,6 @@ export function useNotificationCenter(onNavigate?: () => void) {
error: feed.error,
open,
dismiss: (item: NotificationItem) => dismiss([item.id]),
acknowledge,
acknowledgingWorkOrderId,
dismissAll: () => dismiss(items.filter(isDismissable).map((item) => item.id)),
markAllRead: () => markRead(items.map((item) => item.id)),
};

View file

@ -251,7 +251,6 @@ function mapDashboardStats(raw: unknown): DashboardStats {
scheduledTomorrow: toNumber(readField(data, "scheduledTomorrow")),
pendingUplifts: toNumber(readField(data, "pendingUplifts")),
avetaPending: toNumber(readField(data, "avetaPending")),
unassigned: toNumber(readField(data, "unassigned")),
breakdown: mapBreakdown(readField(data, "breakdown") ?? readField(data, "workOrderBreakdown")),
statusDistribution: mapStatusDistribution(readField(data, "statusDistribution")),
};

View file

@ -20,8 +20,6 @@ export interface DashboardStats {
scheduledTomorrow: number;
pendingUplifts: number;
avetaPending: number;
/** Open WOs with no dispatcher in the selected period. */
unassigned: number;
breakdown: DashboardBreakdownRow[];
statusDistribution: DashboardStatusBucketRow[];
}

View file

@ -1,7 +1,6 @@
import { ALL_WIZARD_STATUSES } from "@/domain/work-orders/types/work-order-wizard";
import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import {
addDaysIso,
businessTodayIso,
@ -78,18 +77,6 @@ export function workOrderRegionDrilldownSearch(
return search;
}
/**
* The Unassigned tile's list: open WOs with no dispatcher in the
* Dashboard period, the same set the backend counts. All time sends no dates,
* so the board searches every week, undated WOs included.
*/
export function unassignedDrilldownSearch(range: DashboardRangeKey): URLSearchParams {
const search = workOrderRangeDrilldownSearch(range);
search.set("dispatchers", ASSIGNEE_FILTER_UNASSIGNED);
search.set("statuses", OPEN_WIZARD_STATUSES.join(","));
return search;
}
export function scheduledTomorrowDrilldownSearch(
today: string = businessTodayIso(),
): URLSearchParams {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,7 +5,6 @@ import {
useRejectUplift,
useRevokeUplift,
} from "@/domain/uplifts/use-cases/use-uplift-actions";
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
export function useUpliftApprovalController() {
const [detailItem, setDetailItem] = useState<UpliftQueueItem | null>(null);
@ -16,7 +15,7 @@ export function useUpliftApprovalController() {
const revokeUplift = useRevokeUplift();
const handleApprove = (row: UpliftQueueItem) => {
approveUplift.mutate({ id: row.id, amount: upliftAmount(row), woNumber: row.woNumber });
approveUplift.mutate({ id: row.id, amount: row.delta, woNumber: row.woNumber });
setDetailItem(null);
};
@ -36,7 +35,7 @@ export function useUpliftApprovalController() {
{
id: rejectTarget.id,
note: reason,
amount: upliftAmount(rejectTarget),
amount: rejectTarget.delta,
woNumber: rejectTarget.woNumber,
},
{ onSuccess: () => setRejectTarget(null) },
@ -49,7 +48,7 @@ export function useUpliftApprovalController() {
{
upliftId: revokeTarget.id,
reason,
amount: upliftAmount(revokeTarget),
amount: revokeTarget.delta,
woNumber: revokeTarget.woNumber,
},
{ onSuccess: () => setRevokeTarget(null) },

View file

@ -1,11 +0,0 @@
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
/**
* The amount of an uplift: the increase being requested on top of the work
* order's current NTE. Every surface that names "the uplift" (queue Amount,
* Approve label, detail modal, reject/revoke dialogs and decision toasts)
* shows this figure; `requestedNTE` is the resulting NTE total, not the uplift.
*/
export function upliftAmount(request: Pick<UpliftRequest, "delta">): number {
return request.delta;
}

View file

@ -1,129 +1,12 @@
import type { VendorPortalMediaCounts } from "@/domain/vendor-portal/types/vendor-portal";
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
/**
* Client-confirmed media contract on the vendor portal: photos up to
* 10 MB (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV).
* PDF documents keep the previous 10 MB document cap.
*/
const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024;
const MAX_PHOTO_BYTES = 10_000_000;
const MAX_VIDEO_BYTES = 100_000_000;
const MAX_VIDEO_DURATION_SECONDS = 90;
const ALLOWED_DOCUMENT_TYPES = new Set(["application/pdf", "image/jpeg", "image/png"]);
const UNSUPPORTED_TYPE_MESSAGE = "Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.";
/** File-picker filter for every vendor portal upload, matching the allowlist below. */
export const VENDOR_UPLOAD_ACCEPT =
".pdf,.jpg,.jpeg,.png,.heic,.mp4,.mov,application/pdf,image/jpeg,image/png,image/heic,video/mp4,video/quicktime";
type VendorUploadKind = "document" | "photo" | "video";
// Mirrors the server allowlist: an allowlisted browser type wins, otherwise the
// extension decides (mobile browsers often send an empty or octet-stream type).
const KIND_BY_MIME: Record<string, VendorUploadKind> = {
"application/pdf": "document",
"image/jpeg": "photo",
"image/jpg": "photo",
"image/png": "photo",
"image/heic": "photo",
"video/mp4": "video",
"video/quicktime": "video",
};
const KIND_BY_EXTENSION: Record<string, VendorUploadKind> = {
pdf: "document",
jpg: "photo",
jpeg: "photo",
png: "photo",
heic: "photo",
mp4: "video",
mov: "video",
};
function resolveKind(file: File): VendorUploadKind | null {
const name = file.name.toLowerCase();
const extension = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : "";
return KIND_BY_MIME[file.type.toLowerCase()] ?? KIND_BY_EXTENSION[extension] ?? null;
}
/** Photos/videos already counted against the work order for one upload, with the limits. */
export type VendorMediaUsage = {
photos: number;
videos: number;
maxPhotos: number;
maxVideos: number;
};
/**
* The count basis the server uses for this upload. A completion upload replaces a
* document: the latest one by default (already excluded in `completion*`), or the
* one the vendor picked, which is not counted when it is the same kind. Evidence
* uploads count everything. Undefined when the backend does not report counts.
*/
export function vendorMediaUsage(
counts: VendorPortalMediaCounts | null | undefined,
purpose: "completion" | "evidence",
replacedContentType?: string,
): VendorMediaUsage | undefined {
if (!counts) return undefined;
const limits = { maxPhotos: counts.maxPhotos, maxVideos: counts.maxVideos };
if (purpose === "evidence") {
return { ...limits, photos: counts.photos, videos: counts.videos };
}
if (replacedContentType === undefined) {
return { ...limits, photos: counts.completionPhotos, videos: counts.completionVideos };
}
const replacedKind = KIND_BY_MIME[replacedContentType.toLowerCase()];
return {
...limits,
photos: Math.max(0, counts.photos - (replacedKind === "photo" ? 1 : 0)),
videos: Math.max(0, counts.videos - (replacedKind === "video" ? 1 : 0)),
};
}
function countRejection(kind: VendorUploadKind, usage: VendorMediaUsage): string | undefined {
if (kind === "photo" && usage.photos >= usage.maxPhotos) {
return `A work order can have at most ${usage.maxPhotos} photos.`;
}
if (kind === "video" && usage.videos >= usage.maxVideos) {
return `A work order can have at most ${usage.maxVideos} videos.`;
}
return undefined;
}
export async function validateVendorDocument(
file: File,
usage?: VendorMediaUsage,
): Promise<string | undefined> {
const kind = resolveKind(file);
if (!kind) {
return UNSUPPORTED_TYPE_MESSAGE;
export function validateVendorDocument(file: File): string | undefined {
if (!ALLOWED_DOCUMENT_TYPES.has(file.type)) {
return "Only PDF, JPG, and PNG files are allowed.";
}
const countMessage = usage && countRejection(kind, usage);
if (countMessage) {
return countMessage;
}
if (kind === "photo" && file.size > MAX_PHOTO_BYTES) {
return "Photos must be 10 MB or smaller.";
}
if (kind === "video") {
if (file.size > MAX_VIDEO_BYTES) {
return "Videos must be 100 MB or smaller.";
}
// Pre-check; the server enforces it too. Unreadable metadata never blocks.
const duration = await probeVideoDurationSeconds(file);
if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) {
return `Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`;
}
}
if (kind === "document" && file.size > MAX_DOCUMENT_BYTES) {
if (file.size > MAX_DOCUMENT_BYTES) {
return "Files must be 10 MB or smaller.";
}
return undefined;
}

View file

@ -48,7 +48,7 @@ export function validateUpliftAmount(
return "Please include a reason for the uplift request.";
}
if (!options.file && !options.hasExisting) {
return "Attach PDF, JPG, PNG, HEIC, MP4, or MOV evidence — approval-required requests need evidence.";
return "Attach PDF, JPG, or PNG evidence — approval-required requests need evidence.";
}
return "";
}

View file

@ -23,16 +23,6 @@ export interface VendorPortalDispatchSummary {
refusedAt?: string;
}
/** Counts for a new completion version exclude the document it replaces. */
export interface VendorPortalMediaCounts {
maxPhotos: number;
maxVideos: number;
photos: number;
videos: number;
completionPhotos: number;
completionVideos: number;
}
export interface VendorPortalDocument {
id: number;
originalFileName: string;
@ -76,8 +66,6 @@ export interface VendorPortalUpliftRequest {
requestedNTE: number;
currentNTE?: number;
vendorReason?: string;
/** False when the request was raised from the work order; the vendor cannot revise or withdraw it. */
raisedByVendor?: boolean;
requestedAt?: string;
decidedAt?: string;
decidedByName?: string;
@ -149,6 +137,4 @@ export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary
upliftRequests: VendorPortalUpliftRequest[];
signoffs: VendorPortalSignoff[];
documents: VendorPortalDocument[];
/** Per-work-order photo/video usage; absent until the backend reports it. */
mediaCounts?: VendorPortalMediaCounts | null;
}

View file

@ -48,11 +48,7 @@ import {
type WorkOrdersListParams,
type WorkOrdersListResult,
} from "@/domain/work-orders/types/work-order-list";
import {
ADVANCED_SEARCH_ALL_WEEKS_FROM,
ADVANCED_SEARCH_ALL_WEEKS_TO,
buildBoardSearchParamsRecord,
} from "@/domain/work-orders/utils/board-query-params";
import { buildBoardSearchParamsRecord } from "@/domain/work-orders/utils/board-query-params";
function buildSearchParams(params: WorkOrdersListParams): Record<string, string | number> {
const searchParams: Record<string, string | number> = {
@ -227,13 +223,8 @@ export const workOrdersApi = {
"Unable to verify the work order number: search result set exceeded the safety page limit.",
);
}
// Every week plus undated rows: a WO# must be unique regardless of schedule.
const result = await workOrdersApi.searchBoard({
search: normalized,
datePreset: "Custom",
dateFrom: ADVANCED_SEARCH_ALL_WEEKS_FROM,
dateTo: ADVANCED_SEARCH_ALL_WEEKS_TO,
includeDateless: true,
page,
pageSize,
});

View file

@ -233,7 +233,7 @@ function cryptoRandomId(): string {
function classifyFileName(fileName: string, url: string): FileCategory {
const name = `${fileName} ${url}`.toLowerCase();
if (/\.(jpe?g|png|heic|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) {
if (/\.(jpe?g|png|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) {
return "image";
}
if (/\.(mp4|mov|webm|m4v)(\?|$)/i.test(name) || name.includes("video/")) {

View file

@ -23,8 +23,7 @@ export type DateRangeKey =
export type CompletionDocFilter = "No" | "Yes" | "NN";
export interface WorkOrderAdvancedFilters {
/** `null` = no date range selected: search every week. */
dateRange: DateRangeKey | null;
dateRange: DateRangeKey;
customFrom: string;
customTo: string;
/** invariant: independent of DateRangeKey; never sent as datePreset=Unscheduled. */
@ -46,12 +45,10 @@ export interface WorkOrderAdvancedFilters {
internalOnly: boolean;
hasUplift: boolean;
upliftStatuses: Set<UpliftFilterStatus>;
/** Exact work orders to show; when non-empty the server ignores every other filter. */
workOrderIds: Set<number>;
}
export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
dateRange: null,
dateRange: "this-week",
customFrom: "",
customTo: "",
unscheduledOnly: false,
@ -72,7 +69,6 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
internalOnly: false,
hasUplift: false,
upliftStatuses: new Set(),
workOrderIds: new Set(),
};
export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [

View file

@ -318,16 +318,9 @@ export interface WorkOrderBoardSearchQueryParams {
* legacy wire behavior.
*/
unscheduledOnly?: boolean;
/**
* `true` adds open rows with no date to the range. Sent only when no
* date range is selected, so an explicit range strictly narrows the results.
*/
includeDateless?: boolean;
hasUplift?: boolean;
upliftStatuses?: string[];
myWorkOrders?: boolean;
/** Comma-separated work-order ids; when present the server returns exactly those, ignoring other filters. */
ids?: string;
sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string;
sortDir?: "asc" | "desc" | string;
}

View file

@ -60,7 +60,7 @@ function severityLabel(level: WorkOrderSeverity): string {
}
export function appendDateChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) {
if (filters.dateRange !== null) {
if (filters.dateRange !== "this-week") {
const value =
filters.dateRange === "custom"
? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}`
@ -71,7 +71,7 @@ export function appendDateChip(chips: AdvancedFilterChip[], filters: WorkOrderAd
value,
clear: (current) => ({
...current,
dateRange: null,
dateRange: "this-week",
customFrom: "",
customTo: "",
}),
@ -306,17 +306,3 @@ export function appendUpliftChip(chips: AdvancedFilterChip[], filters: WorkOrder
clear: clearUplift,
});
}
export function appendWorkOrderIdsChip(
chips: AdvancedFilterChip[],
filters: WorkOrderAdvancedFilters,
) {
const count = filters.workOrderIds?.size ?? 0;
if (count === 0) return;
chips.push({
key: "workOrderIds",
group: "Work orders",
value: `Showing ${count} selected work order${count === 1 ? "" : "s"}`,
clear: (current) => ({ ...current, workOrderIds: new Set<number>() }),
});
}

View file

@ -2,44 +2,18 @@ import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-o
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { dateRangeFor } from "@/domain/work-orders/utils/advanced-filters-utils";
import {
ADVANCED_SEARCH_ALL_WEEKS_FROM,
ADVANCED_SEARCH_ALL_WEEKS_TO,
} from "@/domain/work-orders/utils/board-query-params";
import { isTerminalCarryOverStatus } from "@/domain/work-orders/utils/carry-over-display";
import { addDaysIso } from "@/domain/work-orders/utils/wizard-date-utils";
type AdvancedFilterPredicate = (
row: WorkOrderTableRow,
filters: WorkOrderAdvancedFilters,
) => boolean;
const isOpenUndated = (row: WorkOrderTableRow): boolean =>
!row.scheduledOn && !isTerminalCarryOverStatus(row.status);
// Mirrors the board search (ApplyDateRangeFilter and ApplyUnscheduledOnlyFilter)
// so the page and /board/search keep the same rows: a row is in a range
// when its date is, or when it is week-only and its target week overlaps. With
// no range, every week is searched and open undated rows are added. Closed
// undated rows are never unscheduled.
const matchesDateRange: AdvancedFilterPredicate = (row, filters) => {
if (filters.unscheduledOnly) {
return isOpenUndated(row);
return !row.scheduledOn;
}
const [from, to] = dateRangeFor(filters) ?? [
ADVANCED_SEARCH_ALL_WEEKS_FROM,
ADVANCED_SEARCH_ALL_WEEKS_TO,
];
if (row.scheduledOn && row.scheduledOn >= from && row.scheduledOn <= to) return true;
if (
row.scheduleWeekOnly &&
Boolean(row.targetWeek) &&
row.targetWeek <= to &&
addDaysIso(row.targetWeek, 6) >= from
) {
return true;
}
return filters.dateRange === null && isOpenUndated(row);
const [from, to] = dateRangeFor(filters);
return !row.scheduledOn || (row.scheduledOn >= from && row.scheduledOn <= to);
};
const matchesSite: AdvancedFilterPredicate = (row, filters) =>

View file

@ -12,7 +12,6 @@ import {
appendSeverityChip,
appendStatusPmVendorDocChips,
appendUpliftChip,
appendWorkOrderIdsChip,
type AdvancedFilterChip,
} from "@/domain/work-orders/utils/advanced-filter-chip-builders";
import { addDaysIso, mondayOf, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
@ -34,13 +33,12 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte
docs: new Set(filters.docs),
flagColors: new Set(filters.flagColors ?? []),
upliftStatuses: new Set(filters.upliftStatuses ?? []),
workOrderIds: new Set(filters.workOrderIds ?? []),
unscheduledOnly: Boolean(filters.unscheduledOnly),
};
}
const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): boolean[] => [
filters.dateRange !== null,
filters.dateRange !== "this-week",
Boolean(filters.sites?.size),
Boolean(filters.types?.size),
Boolean(filters.severities?.size),
@ -58,25 +56,12 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool
filters.internalOnly,
filters.hasUplift,
filters.unscheduledOnly,
Boolean(filters.workOrderIds?.size),
];
export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number {
return ADVANCED_FILTER_ACTIVE_SIGNALS(filters).filter(Boolean).length;
}
/**
* The server honours only the id set when one is sent, so an id selection and panel facets never
* combine: applying any facet replaces the selection with a normal filtered board.
*/
export function resolveAppliedAdvancedFilters(
filters: WorkOrderAdvancedFilters,
): WorkOrderAdvancedFilters {
const next = cloneAdvancedFilters(filters);
const withoutIds = { ...next, workOrderIds: new Set<number>() };
return next.workOrderIds.size > 0 && countAdvancedFilters(withoutIds) > 0 ? withoutIds : next;
}
function monthStart(y: number, m: number): string {
return `${y}-${pad2(m + 1)}-01`;
}
@ -85,9 +70,7 @@ function monthEnd(y: number, m: number): string {
return `${y}-${pad2(m + 1)}-${pad2(new Date(y, m + 1, 0).getDate())}`;
}
/** Inclusive [from, to] for the selected range; `null` when no range is selected. */
export function dateRangeFor(filters: WorkOrderAdvancedFilters): [string, string] | null {
if (filters.dateRange === null) return null;
export function dateRangeFor(filters: WorkOrderAdvancedFilters): [string, string] {
const today = todayIso();
const ty = Number(today.slice(0, 4));
const tm = Number(today.slice(5, 7)) - 1;
@ -135,7 +118,6 @@ export function buildAdvancedFilterChips(
appendIndicatorChips(chips, filters);
appendFlagAndInternalChips(chips, filters);
appendUpliftChip(chips, filters);
appendWorkOrderIdsChip(chips, filters);
return chips;
}
@ -162,6 +144,6 @@ export function cloneAdvancedFilters(filters: WorkOrderAdvancedFilters): WorkOrd
return cloneFilters(filters);
}
export function isDefaultDateRange(dateRange: DateRangeKey | null): boolean {
return dateRange === null;
export function isDefaultDateRange(dateRange: DateRangeKey): boolean {
return dateRange === "this-week";
}

View file

@ -1,5 +1,8 @@
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
import {
EMPTY_ADVANCED_FILTERS,
type WorkOrderAdvancedFilters,
} from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
import {
BOARD_DISPATCHER_UNASSIGNED,
@ -12,7 +15,6 @@ import {
} from "@/domain/work-orders/types/work-order-board";
import { mapWizardTypeToApi } from "@/domain/work-orders/mappers/work-order-board-mapper";
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
import { normalizeWorkOrderIds } from "@/domain/work-orders/utils/work-order-ids-filter";
import type { CompletionDocFilter } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard";
@ -134,13 +136,6 @@ function mapSearchFacetParams(
};
}
/** Sent as one comma-separated `ids` value; the server then ignores every other filter. */
function mapWorkOrderIds(ids: Set<number> | undefined): string | undefined {
// Normalized here too, so a caller that fills the set directly still stays within the server's limit.
const normalized = ids?.size ? normalizeWorkOrderIds(ids) : [];
return normalized.length > 0 ? normalized.join(",") : undefined;
}
/** Build GET /board query from weekly filter bar. */
export function toBoardQueryParams(
bar: WorkOrderBarFilters,
@ -163,21 +158,17 @@ function resolveAdvancedSearchDate(filters: WorkOrderAdvancedFilters): {
datePreset?: WorkOrderBoardSearchQueryParams["datePreset"];
dateFrom?: string;
dateTo?: string;
includeDateless?: boolean;
} {
// SH-338: server-side unscheduledOnly owns the date-range exemption; the
// client must not fake a Custom window and sends no date params.
if (filters.unscheduledOnly) {
return {};
}
// No range selected searches every week, undated rows included. Any
// selected range (This week included) strictly narrows the results.
if (filters.dateRange === null) {
if (filters.dateRange === EMPTY_ADVANCED_FILTERS.dateRange) {
return {
datePreset: "Custom",
dateFrom: ADVANCED_SEARCH_ALL_WEEKS_FROM,
dateTo: ADVANCED_SEARCH_ALL_WEEKS_TO,
includeDateless: true,
};
}
@ -222,7 +213,6 @@ export function toBoardSearchQueryParams(input: {
datePreset: date.datePreset,
dateFrom: date.dateFrom,
dateTo: date.dateTo,
includeDateless: date.includeDateless,
sites: filters.sites.size > 0 ? [...filters.sites] : undefined,
types: typeFilters.types,
overdue: typeFilters.overdue,
@ -234,7 +224,6 @@ export function toBoardSearchQueryParams(input: {
docStatuses: mapDocStatuses(filters.docs),
unscheduledOnly: filters.unscheduledOnly || undefined,
...mapSearchFacetParams(filters),
ids: mapWorkOrderIds(filters.workOrderIds),
sortBy: input.sortBy || "scheduledDate",
sortDir: input.sortDir === "desc" ? "desc" : "asc",
};
@ -278,6 +267,5 @@ export function toUnassignedQueueQueryParams(
datePreset: "Custom",
dateFrom: UNASSIGNED_QUEUE_DATE_FROM,
dateTo: UNASSIGNED_QUEUE_DATE_TO,
includeDateless: true,
};
}

View file

@ -5,7 +5,6 @@ import { ALL_WIZARD_STATUSES, WO_TYPES } from "@/domain/work-orders/types/work-o
import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard";
import type { UpliftFilterStatus } from "@/domain/work-orders/types/work-order-uplift-filter";
import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils";
import { parseWorkOrderIdsParam } from "@/domain/work-orders/utils/work-order-ids-filter";
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
@ -64,12 +63,6 @@ function parseUpliftStatuses(raw: string | null): UpliftFilterStatus[] {
export function parseDashboardDrilldownParams(
params: URLSearchParams,
): WorkOrderAdvancedFilters | null {
// An exact id set replaces every other filter: nothing else may hide one of those work orders.
const workOrderIds = parseWorkOrderIdsParam(params.get("ids"));
if (workOrderIds.length > 0) {
return { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), workOrderIds: new Set(workOrderIds) };
}
const dateFrom = (params.get("dateFrom") ?? "").trim();
const dateTo = (params.get("dateTo") ?? "").trim();
const hasDateRange = ISO_DATE_PATTERN.test(dateFrom) && ISO_DATE_PATTERN.test(dateTo);

View file

@ -1,64 +0,0 @@
import {
MAX_PHOTOS_PER_WORK_ORDER,
MAX_VIDEOS_PER_WORK_ORDER,
MAX_VIDEO_DURATION_SECONDS,
resolveWorkOrderUploadKind,
workOrderFileSizeRejection,
type WorkOrderMediaKindCounts,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
export type WorkOrderUploadScreen = {
/** Photos and videos already on the work order, across every surface. */
existing: WorkOrderMediaKindCounts;
isTypeAllowed: (file: File) => boolean;
unsupportedTypeMessage: string;
};
export type WorkOrderUploadScreenResult = { accepted: File[]; rejections: string[] };
/**
* Browser pre-check shared by every dispatcher upload surface: type, size,
* per-work-order photo/video count (including earlier files in the same selection)
* and video duration. The server enforces the same contract; a video whose duration
* the browser cannot read is never blocked here.
*/
export async function screenWorkOrderUploads(
files: readonly File[],
{ existing, isTypeAllowed, unsupportedTypeMessage }: WorkOrderUploadScreen,
): Promise<WorkOrderUploadScreenResult> {
const counts = { ...existing };
const accepted: File[] = [];
const rejections: string[] = [];
for (const file of files) {
if (!isTypeAllowed(file)) {
rejections.push(unsupportedTypeMessage);
continue;
}
const sizeRejection = workOrderFileSizeRejection(file);
if (sizeRejection) {
rejections.push(sizeRejection);
continue;
}
const kind = resolveWorkOrderUploadKind(file);
if (kind === "photo" && counts.photos >= MAX_PHOTOS_PER_WORK_ORDER) {
rejections.push(`A work order can have at most ${MAX_PHOTOS_PER_WORK_ORDER} photos.`);
continue;
}
if (kind === "video" && counts.videos >= MAX_VIDEOS_PER_WORK_ORDER) {
rejections.push(`A work order can have at most ${MAX_VIDEOS_PER_WORK_ORDER} videos.`);
continue;
}
if (kind === "video") {
const duration = await probeVideoDurationSeconds(file);
if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) {
rejections.push(`Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`);
continue;
}
}
if (kind === "photo") counts.photos += 1;
if (kind === "video") counts.videos += 1;
accepted.push(file);
}
return { accepted, rejections };
}

View file

@ -1,26 +0,0 @@
/** The board's exact id filter accepts at most this many distinct work orders (backend limit). */
export const WORK_ORDER_IDS_MAX = 200;
/** Positive integer ids, deduplicated in first-seen order, capped at {@link WORK_ORDER_IDS_MAX}. */
export function normalizeWorkOrderIds(values: Iterable<number | string>): number[] {
const ids = new Set<number>();
for (const value of values) {
const text = typeof value === "number" ? String(value) : value.trim();
if (!/^\d+$/.test(text)) continue;
const id = Number(text);
if (Number.isSafeInteger(id) && id > 0) ids.add(id);
if (ids.size === WORK_ORDER_IDS_MAX) break;
}
return [...ids];
}
/** Reads the `ids` URL param (`101,205,318`); invalid tokens are dropped. */
export function parseWorkOrderIdsParam(raw: string | null): number[] {
return raw ? normalizeWorkOrderIds(raw.split(",")) : [];
}
/** Work Orders showing exactly these work orders, whatever the saved or default filters are. */
export function buildWorkOrderIdsHref(ids: number[]): string {
const normalized = normalizeWorkOrderIds(ids);
return normalized.length > 0 ? `/workorders?ids=${normalized.join(",")}` : "/workorders";
}

View file

@ -1,38 +1,20 @@
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
/**
* Client-confirmed media contract (2026-09-22): photos up to 10 MB
* (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV), at most
* 10 photos and 3 videos per work order. Applies to the Photos & Videos modal,
* the Completion Doc → Media tab and the vendor portal upload. Duration is
* pre-checked here and enforced by the server from the MP4/MOV header;
* unreadable metadata never blocks. Documents
* keep the 50 MB document cap; the signed completion PDF keeps its own cap.
*/
export const MAX_WORK_ORDER_PHOTO_BYTES = 10_000_000;
export const MAX_WORK_ORDER_VIDEO_BYTES = 100_000_000;
export const MAX_WORK_ORDER_DOCUMENT_BYTES = 50_000_000;
/** Backend RequestSizeLimit for media uploads and Extra Docs. */
export const MAX_WORK_ORDER_MEDIA_BYTES = 200_000_000;
/** Backend RequestSizeLimit for completion-document (signed PDF) uploads. */
/** Backend RequestSizeLimit for completion-document uploads. */
export const MAX_WORK_ORDER_COMPLETION_DOC_BYTES = 50_000_000;
/** Largest single upload the media endpoint accepts (RequestSizeLimit ceiling). */
export const MAX_WORK_ORDER_MEDIA_REQUEST_BYTES = MAX_WORK_ORDER_VIDEO_BYTES;
export const MAX_VIDEO_DURATION_SECONDS = 90;
export const MAX_PHOTOS_PER_WORK_ORDER = 10;
export const MAX_VIDEOS_PER_WORK_ORDER = 3;
const MEDIA_MIME_TYPES = new Set([
"image/jpeg",
"image/jpg",
"image/png",
"image/heic",
"video/mp4",
"video/quicktime",
]);
const MEDIA_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".heic", ".mp4", ".mov"]);
const MEDIA_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]);
const EXTRA_DOC_MIME_TYPES = new Set([
...MEDIA_MIME_TYPES,
@ -43,8 +25,6 @@ const EXTRA_DOC_MIME_TYPES = new Set([
const EXTRA_DOC_EXTENSIONS = new Set([...MEDIA_EXTENSIONS, ".pdf", ".doc", ".docx"]);
export type WorkOrderUploadKind = "photo" | "video" | "document";
function fileExtension(file: File): string {
const name = file.name.toLowerCase();
const dot = name.lastIndexOf(".");
@ -57,58 +37,11 @@ function isTypeAllowed(file: File, mimes: Set<string>, extensions: Set<string>):
return mimeOk || extOk;
}
const KIND_BY_MIME: Record<string, WorkOrderUploadKind> = {
"image/jpeg": "photo",
"image/jpg": "photo",
"image/png": "photo",
"image/heic": "photo",
"video/mp4": "video",
"video/quicktime": "video",
};
const KIND_BY_EXTENSION: Record<string, WorkOrderUploadKind> = {
".jpg": "photo",
".jpeg": "photo",
".png": "photo",
".heic": "photo",
".mp4": "video",
".mov": "video",
};
/**
* Same rule as the server: an allowlisted browser type decides, otherwise the
* extension (mobile browsers often send an empty, octet-stream or foreign type).
*/
export function resolveWorkOrderUploadKind(file: File): WorkOrderUploadKind {
return (
KIND_BY_MIME[file.type.toLowerCase()] ?? KIND_BY_EXTENSION[fileExtension(file)] ?? "document"
);
}
function workOrderUploadKindLimit(kind: WorkOrderUploadKind): number {
if (kind === "photo") return MAX_WORK_ORDER_PHOTO_BYTES;
if (kind === "video") return MAX_WORK_ORDER_VIDEO_BYTES;
return MAX_WORK_ORDER_DOCUMENT_BYTES;
}
export function isWorkOrderFileTooLarge(file: File, maxBytes?: number): boolean {
const limit = maxBytes ?? workOrderUploadKindLimit(resolveWorkOrderUploadKind(file));
return file.size > limit;
}
/** Stable per-kind rejection message; never echoes file metadata. */
export function workOrderFileSizeRejection(file: File): string | null {
const kind = resolveWorkOrderUploadKind(file);
if (kind === "photo" && file.size > MAX_WORK_ORDER_PHOTO_BYTES) {
return "Photos must be 10 MB or smaller.";
}
if (kind === "video" && file.size > MAX_WORK_ORDER_VIDEO_BYTES) {
return "Videos must be 100 MB or smaller.";
}
if (kind === "document" && file.size > MAX_WORK_ORDER_DOCUMENT_BYTES) {
return "Documents must be 50 MB or smaller.";
}
return null;
export function isWorkOrderFileTooLarge(
file: File,
maxBytes = MAX_WORK_ORDER_MEDIA_BYTES,
): boolean {
return file.size > maxBytes;
}
export function isWorkOrderMediaTypeAllowed(file: File): boolean {
@ -120,34 +53,17 @@ export function isWorkOrderExtraDocTypeAllowed(file: File): boolean {
}
export function workOrderMediaAcceptAttribute(): string {
return "image/jpeg,image/jpg,image/png,image/heic,video/mp4,video/quicktime,.jpg,.jpeg,.png,.heic,.mp4,.mov";
return "image/jpeg,image/jpg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov";
}
export function workOrderExtraDocsAcceptAttribute(): string {
return `${workOrderMediaAcceptAttribute()},.pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document`;
}
export function formatMediaSizeLimit(maxBytes: number): string {
export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string {
return `${Math.round(maxBytes / 1_000_000)} MB`;
}
export type WorkOrderMediaKindCounts = { photos: number; videos: number };
/** Counts the gallery items the server's per-work-order counts cover (non-legacy rows). */
export function countWorkOrderMediaKinds(
uploads: Array<{ fileType?: string; isLegacy?: boolean; uploadStatus?: string }>,
): WorkOrderMediaKindCounts {
return uploads.reduce(
(counts, item) => {
if (item.isLegacy || item.uploadStatus === "failed") return counts;
if (item.fileType === "image") counts.photos += 1;
if (item.fileType === "video") counts.videos += 1;
return counts;
},
{ photos: 0, videos: 0 },
);
}
/** Completion-doc / completion-media lock: Canceled or Completed. Extra Docs use canMutateWorkOrderExtraDocs. */
export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean {
return isWorkOrderCompletionSurfaceReadOnly(status);

View file

@ -3,7 +3,6 @@ import type {
MediaCategory,
MediaFile,
} from "@/domain/work-orders/types/work-order-media";
import { resolveWorkOrderUploadKind } from "@/domain/work-orders/utils/work-order-media-limits";
let mediaIdCounter = 0;
@ -17,10 +16,6 @@ export function classifyUpload(f: File): {
if (t === "application/pdf" || /\.pdf$/i.test(f.name)) {
return { fileType: "pdf", category: "Report" };
}
// Phones often send an empty or octet-stream type; fall back to the extension.
const kind = resolveWorkOrderUploadKind(f);
if (kind === "photo") return { fileType: "image", category: "Photo" };
if (kind === "video") return { fileType: "video", category: "Video" };
return { fileType: "other", category: "Other" };
}

View file

@ -1,75 +0,0 @@
/**
* Best-effort video duration probe for MP4/MOV. Reads the movie header
* (`moov` → `mvhd`) the same way the server does, seeking over box headers so a
* phone file with `moov` after a large `mdat` costs only a few small reads.
* Resolves null when the header cannot be read — callers must not block the
* upload in that case (the server enforces the limit from the same
* header and also lets unreadable metadata through).
*/
const MAX_BOXES_PER_LEVEL = 1024;
type Box = { bodyStart: number; end: number };
async function readView(file: Blob, start: number, length: number): Promise<DataView | null> {
if (start < 0 || length <= 0 || start + length > file.size) return null;
const buffer = await file.slice(start, start + length).arrayBuffer();
return buffer.byteLength === length ? new DataView(buffer) : null;
}
function boxType(view: DataView): string {
return String.fromCharCode(
view.getUint8(4),
view.getUint8(5),
view.getUint8(6),
view.getUint8(7),
);
}
async function findBox(file: Blob, start: number, end: number, type: string): Promise<Box | null> {
let offset = start;
for (let index = 0; index < MAX_BOXES_PER_LEVEL && offset + 8 <= end; index += 1) {
const header = await readView(file, offset, Math.min(16, end - offset));
if (!header) return null;
let size = header.getUint32(0);
let headerLength = 8;
if (size === 1) {
if (header.byteLength < 16) return null;
const largeSize = header.getBigUint64(8);
if (largeSize > BigInt(Number.MAX_SAFE_INTEGER)) return null;
size = Number(largeSize);
headerLength = 16;
} else if (size === 0) {
size = end - offset;
}
if (size < headerLength || offset + size > end) return null;
if (boxType(header) === type) {
return { bodyStart: offset + headerLength, end: offset + size };
}
offset += size;
}
return null;
}
async function readMovieHeaderSeconds(file: Blob, mvhd: Box): Promise<number | null> {
const version = await readView(file, mvhd.bodyStart, 1);
if (!version) return null;
const isVersion1 = version.getUint8(0) === 1;
const fields = await readView(file, mvhd.bodyStart, isVersion1 ? 32 : 20);
if (!fields || mvhd.bodyStart + fields.byteLength > mvhd.end) return null;
const timescale = fields.getUint32(isVersion1 ? 20 : 12);
const duration = isVersion1 ? fields.getBigUint64(24) : BigInt(fields.getUint32(16));
const unknown = isVersion1 ? 0xffff_ffff_ffff_ffffn : 0xffff_ffffn;
if (timescale === 0 || duration === unknown) return null;
return Number(duration) / timescale;
}
export async function probeVideoDurationSeconds(file: Blob): Promise<number | null> {
try {
const moov = await findBox(file, 0, file.size, "moov");
if (!moov) return null;
const mvhd = await findBox(file, moov.bodyStart, moov.end, "mvhd");
return mvhd ? await readMovieHeaderSeconds(file, mvhd) : null;
} catch {
return null;
}
}

View file

@ -63,7 +63,6 @@ const statsData = {
scheduledTomorrow: 3,
pendingUplifts: 5,
avetaPending: 0,
unassigned: 4,
breakdown: [
{ status: "PM", count: 7 },
{ status: "Completed", count: 0 },
@ -89,11 +88,6 @@ function renderDashboard() {
return renderWithProviders(<DashboardPage />);
}
function lastNavigation(): string {
const calls = navigate.mock.calls;
return String(calls[calls.length - 1]?.[0]);
}
describe("DashboardPage", () => {
beforeEach(() => {
authUser.current = { id: "99", userRoles: "Admin" };
@ -126,7 +120,6 @@ describe("DashboardPage", () => {
"Scheduled Tomorrow",
"Pending Uplifts",
"Aveta Pending",
"Unassigned",
"Work Order Breakdown",
"Status Distribution",
"Dispatcher Workload",
@ -264,29 +257,6 @@ describe("DashboardPage", () => {
expect(lastRequestedPage).toBe(1);
});
it("shows unassigned WOs for the selected period and drills into that list (SH-392)", () => {
renderDashboard();
const card = screen.getByRole("button", { name: /Unassigned/ });
expect(card).toHaveTextContent("4");
fireEvent.click(card);
const weekUrl = new URL(lastNavigation(), "http://x");
expect(weekUrl.pathname).toBe("/workorders");
expect(weekUrl.searchParams.get("dispatchers")).toBe("__unassigned");
expect(weekUrl.searchParams.get("statuses")).not.toContain("Completed");
expect(weekUrl.searchParams.get("dateFrom")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(weekUrl.searchParams.get("dateTo")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
fireEvent.click(screen.getByRole("button", { name: "All time" }));
fireEvent.click(screen.getByRole("button", { name: /Unassigned/ }));
const allTimeUrl = new URL(lastNavigation(), "http://x");
expect(allTimeUrl.searchParams.get("dispatchers")).toBe("__unassigned");
expect(allTimeUrl.searchParams.has("dateFrom")).toBe(false);
expect(allTimeUrl.searchParams.has("dateTo")).toBe(false);
});
it("keeps drilldown dates deterministic on the business clock", () => {
const today = businessTodayIso();

View file

@ -132,7 +132,7 @@ describe("Uplift Approvals approved tab", () => {
expect(screen.getByRole("heading", { name: "Revoke this approval?" })).toBeInTheDocument();
expect(
screen.getByText(
"The approved uplift of $400.00 on WO WO-56 will be withdrawn. This does not recover money already spent — it records that the authorization was a mistake.",
"The approved uplift of $900.00 on WO WO-56 will be withdrawn. This does not recover money already spent — it records that the authorization was a mistake.",
),
).toBeInTheDocument();
});

View file

@ -1,189 +0,0 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UpliftQueueItem, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
import { renderWithProviders } from "@/test/test-utils";
// A work order that already carries an NTE: the uplift (the increase) and the
// requested NTE total differ, so any surface reading the wrong field shows up.
const UPLIFT = "$4,999,997,000.00";
const REQUESTED_NTE_TOTAL = "$5,000,000,000.00";
const api = vi.hoisted(() => ({
approve: vi.fn(),
reject: vi.fn(),
revoke: vi.fn(),
}));
const toastSuccess = vi.hoisted(() => vi.fn());
const queueState = vi.hoisted(() => ({
data: null as UpliftsQueueResult | null,
}));
vi.mock("@/domain/uplifts/api/uplifts-api", () => ({
upliftsApi: {
approve: (...args: unknown[]) => api.approve(...args),
reject: (...args: unknown[]) => api.reject(...args),
revoke: (...args: unknown[]) => api.revoke(...args),
},
}));
vi.mock("react-toastify", async (importOriginal) => ({
...(await importOriginal<object>()),
toast: { error: vi.fn(), success: toastSuccess, warning: vi.fn() },
}));
vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({
useUpliftsQueue: () => ({
data: queueState.data,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
}),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({
useWorkOrderUplifts: () => ({ data: [], isLoading: false, isError: false }),
}));
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
useUpliftsCanApprove: () => ({ data: true }),
}));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<object>()),
useAuthContext: () => ({ user: { userRoles: "Admin" } }),
}));
const pendingItem: UpliftQueueItem = {
id: 41,
status: "Pending",
currentNTE: 3000,
requestedNTE: 5_000_000_000,
delta: 4_999_997_000,
vendorReason: "Full replacement",
requestedAt: "2026-01-15T10:00:00Z",
requestedByVendorName: "Gateway",
decidedAt: "",
decidedByName: "",
decisionNote: "",
requiredTier: 1,
canDecide: true,
expiresAt: "",
notificationStatus: "",
notificationError: "",
evidenceDocumentId: null,
evidenceFileName: "",
evidenceContentType: "",
evidenceSizeBytes: null,
dispatchNumber: "DSP-41",
poNumber: "PO-41",
vendorCompanyName: "Gateway Plumbing",
workOrderId: 99,
dispatchId: 7,
woNumber: "WO-99",
site: "Site A",
serviceName: "Plumbing repair",
technicianName: "",
workOrderDispatcherName: "",
workOrderScheduledDate: "",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
approvedOnWoTotal: null,
workOrderClosed: false,
};
const approvedItem: UpliftQueueItem = {
...pendingItem,
id: 42,
status: "Approved",
decidedAt: "2026-01-16T10:00:00Z",
decidedByName: "Admin User",
canDecide: false,
};
function queueOf(item: UpliftQueueItem): UpliftsQueueResult {
return { items: [item], totalCount: 1, page: 1, pageSize: 25, pendingExposureTotal: null };
}
async function renderQueue() {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
}
function amountCell(): HTMLElement {
const row = screen.getByLabelText(/open uplift details for/i);
const header = screen.getByRole("columnheader", { name: "Amount" });
const index = Array.from(header.parentElement?.children ?? []).indexOf(header);
return row.querySelectorAll("td")[index] as HTMLElement;
}
describe("Uplift Approvals shows one amount per request", () => {
beforeEach(() => {
api.approve.mockReset().mockResolvedValue(undefined);
api.reject.mockReset().mockResolvedValue(undefined);
api.revoke.mockReset().mockResolvedValue(undefined);
toastSuccess.mockReset();
});
it("uses the uplift amount in the pending row, the detail modal and the approve toast", async () => {
queueState.data = queueOf(pendingItem);
await renderQueue();
expect(amountCell()).toHaveTextContent(UPLIFT);
expect(screen.getByRole("button", { name: `Approve ${UPLIFT}` })).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/open uplift details for/i));
const modal = screen.getByRole("dialog");
expect(within(modal).getByText(UPLIFT)).toBeInTheDocument();
fireEvent.click(within(modal).getByRole("button", { name: `Approve ${UPLIFT}` }));
await waitFor(() =>
expect(toastSuccess).toHaveBeenCalledWith(`Uplift approved — ${UPLIFT} on WO-99`),
);
expect(screen.queryByText(REQUESTED_NTE_TOTAL)).not.toBeInTheDocument();
});
it("uses the uplift amount in the reject dialog title and the reject toast", async () => {
queueState.data = queueOf(pendingItem);
await renderQueue();
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
expect(
screen.getByRole("heading", { name: `Reject uplift of ${UPLIFT}?` }),
).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/reason for rejection/i), {
target: { value: "Out of scope" },
});
fireEvent.click(screen.getByRole("button", { name: "Reject uplift" }));
await waitFor(() =>
expect(toastSuccess).toHaveBeenCalledWith(`Uplift rejected — ${UPLIFT} on WO-99`),
);
expect(screen.queryByText(REQUESTED_NTE_TOTAL)).not.toBeInTheDocument();
});
it("uses the uplift amount in the approved row, the revoke dialog and the revoke toast", async () => {
queueState.data = queueOf(approvedItem);
await renderQueue();
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
expect(amountCell()).toHaveTextContent(UPLIFT);
fireEvent.click(screen.getByRole("button", { name: "Revoke" }));
expect(
screen.getByText(
`The approved uplift of ${UPLIFT} on WO WO-99 will be withdrawn. This does not recover money already spent — it records that the authorization was a mistake.`,
),
).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/reason for revoking/i), {
target: { value: "Approved in error" },
});
fireEvent.click(screen.getByRole("button", { name: `Revoke ${UPLIFT}` }));
await waitFor(() =>
expect(toastSuccess).toHaveBeenCalledWith(`Uplift revoked — ${UPLIFT} on WO-99`),
);
expect(screen.queryByText(REQUESTED_NTE_TOTAL)).not.toBeInTheDocument();
});
});

View file

@ -1,160 +0,0 @@
import { fireEvent, screen, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UpliftQueueItem, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
import { renderWithProviders } from "@/test/test-utils";
const queueState = vi.hoisted(() => ({ data: null as UpliftsQueueResult | null }));
const woUplifts = vi.hoisted(() => ({
data: [] as Array<Record<string, unknown>> | null,
isError: false,
}));
vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({
useUpliftsQueue: () => ({
data: queueState.data,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
}),
}));
vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
useApproveUplift: () => ({ isPending: false, mutate: vi.fn() }),
useRejectUplift: () => ({ isPending: false, mutate: vi.fn() }),
useRevokeUplift: () => ({ isPending: false, mutate: vi.fn() }),
}));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<object>()),
useAuthContext: () => ({ user: { userRoles: "Admin" } }),
}));
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
useUpliftsCanApprove: () => ({ data: true }),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({
useWorkOrderUplifts: () => ({
data: woUplifts.data,
isLoading: false,
isError: woUplifts.isError,
}),
}));
const pendingItem: UpliftQueueItem = {
id: 41,
status: "Pending",
currentNTE: 100,
requestedNTE: 250,
delta: 150,
vendorReason: "Extra parts",
requestedAt: "2026-01-15T10:00:00Z",
requestedByVendorName: "Gateway",
decidedAt: "",
decidedByName: "",
decisionNote: "",
requiredTier: 1,
canDecide: true,
expiresAt: "",
notificationStatus: "",
notificationError: "",
evidenceDocumentId: null,
evidenceFileName: "",
evidenceContentType: "",
evidenceSizeBytes: null,
dispatchNumber: "DSP-41",
poNumber: "PO-41",
vendorCompanyName: "Gateway Plumbing",
workOrderId: 99,
dispatchId: 7,
woNumber: "WO-99",
site: "Site A",
serviceName: "Plumbing repair",
technicianName: "",
workOrderDispatcherName: "",
workOrderScheduledDate: "",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
approvedOnWoTotal: null,
workOrderClosed: false,
};
function seedQueue(overrides: Partial<UpliftQueueItem>) {
queueState.data = {
items: [{ ...pendingItem, ...overrides }],
totalCount: 1,
page: 1,
pageSize: 25,
pendingExposureTotal: 250,
};
}
async function renderQueuePage() {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
}
function approvedOnWoColumnText(): string {
const table = screen.getByRole("table", { name: "pending uplift requests" });
const headers = within(table).getAllByRole("columnheader");
const columnIndex = headers.findIndex((header) => header.textContent === "Approved on WO");
const row = within(table).getByRole("row", { name: /Open uplift details for WO-99/ });
return within(row).getAllByRole("cell")[columnIndex]?.textContent ?? "";
}
function modalTotalText(): string {
fireEvent.click(screen.getByRole("row", { name: /Open uplift details for WO-99/ }));
const dialog = screen.getByRole("dialog");
const label = within(dialog).getByText("Total");
return label.parentElement?.lastElementChild?.textContent ?? "";
}
describe("Uplift Approvals: Approved on WO", () => {
beforeEach(() => {
woUplifts.data = [];
woUplifts.isError = false;
});
it.each([
{ total: 0, expected: "—" },
{ total: null, expected: "—" },
{ total: 125, expected: "$125.00" },
])(
"renders $expected in the column and the modal Total for total $total",
async ({ total, expected }) => {
seedQueue({ approvedOnWoTotal: total });
await renderQueuePage();
const column = approvedOnWoColumnText();
const modalTotal = modalTotalText();
expect(column).toBe(expected);
expect(modalTotal).toBe(column);
},
);
it("shows a dash in both places when the row total is unknown and the WO has nothing granted", async () => {
woUplifts.data = [
{ id: 1, status: "pending", amount: 250 },
{ id: 2, status: "rejected", amount: 80 },
{ id: 3, status: "revoked", amount: 40 },
];
seedQueue({ approvedOnWoTotal: null });
await renderQueuePage();
expect(approvedOnWoColumnText()).toBe("—");
expect(modalTotalText()).toBe("—");
});
it("keeps the modal Total as a dash when the WO uplift lookup fails but the row reports zero", async () => {
woUplifts.data = null;
woUplifts.isError = true;
seedQueue({ approvedOnWoAuto: 0, approvedOnWoAdmin: 0, approvedOnWoTotal: 0 });
await renderQueuePage();
expect(approvedOnWoColumnText()).toBe("—");
expect(modalTotalText()).toBe("—");
});
});

View file

@ -195,10 +195,10 @@ describe("UpliftDetailModal", () => {
const footerButtons = screen
.getAllByRole("button")
.map((button) => button.textContent)
.filter((label) => label === "Reject" || label === "Approve $150.00" || label === "Revoke");
expect(footerButtons).toEqual(["Reject", "Approve $150.00"]);
.filter((label) => label === "Reject" || label === "Approve" || label === "Revoke");
expect(footerButtons).toEqual(["Reject", "Approve"]);
fireEvent.click(screen.getByRole("button", { name: "Approve $150.00" }));
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
expect(onApprove).toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
expect(onReject).toHaveBeenCalled();
@ -208,7 +208,7 @@ describe("UpliftDetailModal", () => {
canApproveState.data = false;
const { onApprove, onReject } = renderModal();
const approve = screen.getByRole("button", { name: "Approve $150.00" });
const approve = screen.getByRole("button", { name: "Approve" });
const reject = screen.getByRole("button", { name: "Reject" });
expect(approve).toBeDisabled();
expect(reject).toBeDisabled();
@ -223,7 +223,7 @@ describe("UpliftDetailModal", () => {
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: "Approve" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument();
const revoke = screen.getByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();

View file

@ -106,7 +106,7 @@ describe("Uplift Approvals decision flow", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("button", { name: "Approve $150.00" }));
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
expect(approveMutate).toHaveBeenCalledWith({ id: 41, amount: 150, woNumber: "WO-99" });
expect(screen.queryByRole("button", { name: "Confirm" })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
@ -117,7 +117,7 @@ describe("Uplift Approvals decision flow", () => {
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
expect(screen.getByRole("heading", { name: "Reject uplift of $150.00?" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /reject uplift of \$250/i })).toBeInTheDocument();
expect(screen.getByText("The dispatcher sees this reason on WO #WO-99.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(rejectMutate).not.toHaveBeenCalled();
@ -142,7 +142,7 @@ describe("Uplift Approvals decision flow", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
const approve = screen.getByRole("button", { name: "Approve $150.00" });
const approve = screen.getByRole("button", { name: "Approve" });
const reject = screen.getByRole("button", { name: "Reject" });
expect(approve).toBeDisabled();
expect(reject).toBeDisabled();
@ -160,7 +160,7 @@ describe("Uplift Approvals decision flow", () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
expect(screen.getByRole("button", { name: "Approve $150.00" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Approve" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Reject" })).toBeEnabled();
});
});

View file

@ -1,63 +0,0 @@
import { useEffect, useState } from "react";
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AdvDateRangeSection } from "@/app/(protected)/workorders/_components/list/advanced-search/adv-date-range-section";
import {
EMPTY_ADVANCED_FILTERS,
type WorkOrderAdvancedFilters,
} from "@/domain/work-orders/types/work-order-advanced-filters";
import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils";
import { toBoardSearchQueryParams } from "@/domain/work-orders/utils/board-query-params";
import { renderWithProviders } from "@/test/test-utils";
const onDraft = vi.fn<(draft: WorkOrderAdvancedFilters) => void>();
function latestDraft(): WorkOrderAdvancedFilters {
return onDraft.mock.calls[onDraft.mock.calls.length - 1][0];
}
function Harness() {
const [draft, setDraft] = useState(() => cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS));
useEffect(() => {
onDraft(draft);
}, [draft]);
return <AdvDateRangeSection draft={draft} setDraft={setDraft} />;
}
function openSection() {
onDraft.mockClear();
renderWithProviders(<Harness />);
const header = screen.queryByRole("button", { name: /Date range/i });
if (header && !screen.queryByRole("button", { name: /^This week$/i })) {
fireEvent.click(header);
}
}
describe("Advanced Filters date range", () => {
it("starts with no range selected, so the search spans every week", () => {
openSection();
for (const label of ["This week", "Last week", "Last 3 months", "Custom"]) {
expect(screen.getByRole("button", { name: new RegExp(`^${label}$`, "i") })).toHaveAttribute(
"aria-pressed",
"false",
);
}
expect(toBoardSearchQueryParams({ filters: latestDraft() }).includeDateless).toBe(true);
});
it("narrows to This week once picked, and clicking it again clears the range", () => {
openSection();
const thisWeek = screen.getByRole("button", { name: /^This week$/i });
fireEvent.click(thisWeek);
expect(thisWeek).toHaveAttribute("aria-pressed", "true");
const narrowed = toBoardSearchQueryParams({ filters: latestDraft() });
expect(narrowed.datePreset).toBe("ThisWeek");
expect(narrowed.includeDateless).toBeUndefined();
fireEvent.click(thisWeek);
expect(thisWeek).toHaveAttribute("aria-pressed", "false");
expect(latestDraft().dateRange).toBeNull();
});
});

View file

@ -117,9 +117,7 @@ describe("MediaUploadModal API wiring", () => {
});
expect(screen.getByText("Photos & Videos")).toBeInTheDocument();
expect(
screen.getByText(
"Accepted: JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB (max 10) · Videos up to 100 MB and 90 s (max 3)",
),
screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"),
).toBeInTheDocument();
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
});

View file

@ -1,39 +1,19 @@
import { createRef } from "react";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { MediaUploaderDropzone } from "@/app/(protected)/workorders/_components/media/media-uploader-dropzone";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import { workOrderMediaAcceptAttribute } from "@/domain/work-orders/utils/work-order-media-limits";
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
import {
MAX_WORK_ORDER_MEDIA_BYTES,
workOrderMediaAcceptAttribute,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() }));
const MB = 1_000_000;
function phoneFile(name: string, type: string, size: number): File {
return { name, type, size } as File;
}
function gallery(photos: number, videos: number): MediaFile[] {
const item = (id: string, fileType: "image" | "video") =>
({ id, fileType, isLegacy: false }) as unknown as MediaFile;
return [
...Array.from({ length: photos }, (_, i) => item(`p${i + 1}`, "image")),
...Array.from({ length: videos }, (_, i) => item(`v${i + 1}`, "video")),
];
}
function selectFiles(files: File[]) {
const input = document.querySelector('input[type="file"]');
fireEvent.change(input as HTMLInputElement, { target: { files } });
}
describe("MediaUploaderDropzone", () => {
it("shows the SH-116 media contract and keeps the picker media-only", () => {
it("matches the AAP Photos accept copy and keeps it media-only", () => {
renderWithProviders(
<MediaUploaderDropzone
inputRef={createRef<HTMLInputElement>()}
@ -43,139 +23,28 @@ describe("MediaUploaderDropzone", () => {
);
expect(
screen.getByText(
"Accepted: JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB (max 10) · Videos up to 100 MB and 90 s (max 3)",
),
screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"),
).toBeInTheDocument();
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
expect(screen.queryByText(/PDF/)).not.toBeInTheDocument();
expect(screen.queryByText(/DOC/)).not.toBeInTheDocument();
const input = document.querySelector('input[type="file"]');
expect(input?.getAttribute("accept")).toBe(workOrderMediaAcceptAttribute());
expect(input?.getAttribute("accept")).not.toContain(".pdf");
});
});
describe("MediaUploader media contract (Completion Doc → Media, Photos & Videos modal)", () => {
beforeEach(() => {
vi.mocked(toast.error).mockClear();
vi.mocked(probeVideoDurationSeconds).mockReset();
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
});
it("rejects an oversized media file with the generic 200 MB toast", () => {
renderWithProviders(<MediaUploader uploads={[]} />);
it("accepts ~60 MB MP4 and MOV phone videos, including mobile MIME variants", async () => {
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
const oversizedFile = {
name: "mobile-clip.MP4",
type: "application/octet-stream",
size: MAX_WORK_ORDER_MEDIA_BYTES + 1,
} as File;
const input = document.querySelector('input[type="file"]');
const videos = [
phoneFile("clip.mp4", "video/mp4", 60 * MB),
phoneFile("IMG_0001.MOV", "video/quicktime", 60 * MB),
phoneFile("IMG_0002.MOV", "", 60 * MB),
];
selectFiles(videos);
fireEvent.change(input as HTMLInputElement, { target: { files: [oversizedFile] } });
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
expect(onUpload.mock.calls[0][0].map((r: { file: File }) => r.file)).toEqual(videos);
expect(toast.error).not.toHaveBeenCalled();
});
it("accepts an octet-stream video with a video extension", async () => {
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
selectFiles([phoneFile("VID_0003.mp4", "application/octet-stream", 60 * MB)]);
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
});
it("rejects a 101 MB video with the generic message and no file name", async () => {
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
selectFiles([phoneFile("secret-site-clip.mp4", "video/mp4", 101 * MB)]);
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Videos must be 100 MB or smaller."),
);
expect(onUpload).not.toHaveBeenCalled();
});
it("rejects an 11 MB photo", async () => {
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
selectFiles([phoneFile("IMG_0100.jpg", "image/jpeg", 11 * MB)]);
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Photos must be 10 MB or smaller."),
);
expect(onUpload).not.toHaveBeenCalled();
});
it("rejects the 11th photo and the 4th video on a full work order", async () => {
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={gallery(10, 3)} onUpload={onUpload} />);
selectFiles([
phoneFile("IMG_0200.jpg", "image/jpeg", 2 * MB),
phoneFile("IMG_0201.MOV", "video/quicktime", 20 * MB),
]);
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(2));
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 10 photos.");
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos.");
expect(onUpload).not.toHaveBeenCalled();
});
it("counts the whole work order's media when this tab shows only its share", async () => {
const onUpload = vi.fn();
renderWithProviders(
<MediaUploader uploads={[]} mediaCounts={{ photos: 10, videos: 3 }} onUpload={onUpload} />,
);
selectFiles([
phoneFile("IMG_0210.jpg", "image/jpeg", 2 * MB),
phoneFile("IMG_0211.MOV", "video/quicktime", 20 * MB),
]);
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(2));
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 10 photos.");
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos.");
expect(onUpload).not.toHaveBeenCalled();
});
it("counts files within one selection against the remaining slots", async () => {
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={gallery(0, 2)} onUpload={onUpload} />);
const first = phoneFile("IMG_0301.MOV", "video/quicktime", 20 * MB);
selectFiles([first, phoneFile("IMG_0302.MOV", "video/quicktime", 20 * MB)]);
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
expect(onUpload.mock.calls[0][0].map((r: { file: File }) => r.file)).toEqual([first]);
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos.");
});
it("rejects a video longer than 90 seconds when the duration is readable", async () => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(95);
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
selectFiles([phoneFile("IMG_0400.MOV", "video/quicktime", 40 * MB)]);
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Videos must be 90 seconds or shorter."),
);
expect(onUpload).not.toHaveBeenCalled();
});
it("does not block a video whose duration the browser cannot read", async () => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null);
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
selectFiles([phoneFile("IMG_0500.MOV", "video/quicktime", 40 * MB)]);
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
expect(toast.error).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith('"mobile-clip.MP4" exceeds the 200 MB limit.');
});
});

View file

@ -140,15 +140,13 @@ describe("SlideOverExtrasTab Aveta", () => {
expect(getMediaContent).not.toHaveBeenCalled();
});
it("shows Extra Docs accept copy with the SH-116 media contract", () => {
it("shows Extra Docs accept copy with PDF DOC and 200MB", () => {
renderWithProviders(
<SlideOverExtrasTab uploads={[]} isReadOnly={false} onUpload={vi.fn()} onRemove={vi.fn()} />,
);
expect(
screen.getByText(
"Accepted: JPG, PNG, HEIC, MP4, MOV, PDF, DOC · Photos up to 10 MB · Videos up to 100 MB and 90 s · Docs up to 50 MB",
),
screen.getByText("Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max 200MB per file"),
).toBeInTheDocument();
const input = document.querySelector('input[type="file"]');
expect(input?.getAttribute("accept")).toContain(".pdf");

View file

@ -1,114 +0,0 @@
import { fireEvent, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab";
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() }));
const MB = 1_000_000;
function phoneFile(name: string, type: string, size: number): File {
return { name, type, size } as File;
}
function selectFiles(files: File[]) {
const input = document.querySelector('input[type="file"]');
fireEvent.change(input as HTMLInputElement, { target: { files } });
}
describe("SlideOverExtrasTab SH-116 media contract", () => {
beforeEach(() => {
vi.mocked(toast.error).mockClear();
vi.mocked(probeVideoDurationSeconds).mockReset();
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
});
it("rejects a video longer than 90 seconds before upload", async () => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(95);
const onUpload = vi.fn();
renderWithProviders(
<SlideOverExtrasTab uploads={[]} isReadOnly={false} onUpload={onUpload} onRemove={vi.fn()} />,
);
selectFiles([phoneFile("IMG_0700.MOV", "video/quicktime", 40 * MB)]);
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Videos must be 90 seconds or shorter."),
);
expect(onUpload).not.toHaveBeenCalled();
});
it("rejects the 11th photo and 4th video counted across the whole work order", async () => {
const onUpload = vi.fn();
const report = phoneFile("inspection.pdf", "application/pdf", 2 * MB);
renderWithProviders(
<SlideOverExtrasTab
uploads={[]}
mediaCounts={{ photos: 10, videos: 3 }}
isReadOnly={false}
onUpload={onUpload}
onRemove={vi.fn()}
/>,
);
selectFiles([
phoneFile("IMG_0701.jpg", "image/jpeg", 2 * MB),
phoneFile("IMG_0702.MOV", "video/quicktime", 20 * MB),
report,
]);
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
expect(onUpload).toHaveBeenCalledWith([report]);
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 10 photos.");
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos.");
});
it("accepts a ~60 MB phone video whose duration the browser cannot read", async () => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null);
const onUpload = vi.fn();
const video = phoneFile("IMG_0703.MOV", "", 60 * MB);
renderWithProviders(
<SlideOverExtrasTab uploads={[]} isReadOnly={false} onUpload={onUpload} onRemove={vi.fn()} />,
);
selectFiles([video]);
await waitFor(() => expect(onUpload).toHaveBeenCalledWith([video]));
expect(toast.error).not.toHaveBeenCalled();
});
it("ignores a second pick while the first one is still being checked", async () => {
let finishProbe: ((seconds: number) => void) | undefined;
vi.mocked(probeVideoDurationSeconds).mockImplementationOnce(
() =>
new Promise((resolve) => {
finishProbe = resolve;
}),
);
const onUpload = vi.fn();
renderWithProviders(
<SlideOverExtrasTab
uploads={[]}
mediaCounts={{ photos: 0, videos: 2 }}
isReadOnly={false}
onUpload={onUpload}
onRemove={vi.fn()}
/>,
);
const first = phoneFile("IMG_0710.MOV", "video/quicktime", 20 * MB);
const second = phoneFile("IMG_0711.MOV", "video/quicktime", 20 * MB);
selectFiles([first]);
await waitFor(() => expect(finishProbe).toBeDefined());
expect(document.querySelector('input[type="file"]')).toBeDisabled();
selectFiles([second]);
finishProbe?.(30);
await waitFor(() => expect(onUpload).toHaveBeenCalledOnce());
expect(onUpload).toHaveBeenCalledWith([first]);
expect(probeVideoDurationSeconds).toHaveBeenCalledOnce();
});
});

View file

@ -1,172 +0,0 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useLocation } from "react-router";
import { describe, expect, it } from "vitest";
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params";
import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters";
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils";
import { buildBoardSearchParamsRecord } from "@/domain/work-orders/utils/board-query-params";
import {
buildWorkOrderIdsHref,
WORK_ORDER_IDS_MAX,
} from "@/domain/work-orders/utils/work-order-ids-filter";
import { renderWithProviders } from "@/test/test-utils";
/** The Work Orders page's filter wiring, without the table: URL → applied filters → board/search params. */
function BoardFilterHarness() {
const filters = useWorkOrdersListFilters();
useDashboardDrilldownFilters(filters.applyAdvancedFilters);
const location = useLocation();
const params = buildResolvedBoardSearchParams({
advApplied: filters.advApplied,
search: "",
page: 0,
pageSize: 200,
sortBy: "",
sortDir: "",
locations: [],
vendors: [],
});
return (
<>
<output aria-label="Board search params">
{params == null ? "weekly board" : JSON.stringify(buildBoardSearchParamsRecord(params))}
</output>
<output aria-label="Current search">{location.search}</output>
<button
type="button"
onClick={() => filters.applyAdvancedFilters({ ...filters.advInitial, rescheduled: true })}
>
Apply rescheduled only
</button>
<button type="button" onClick={() => filters.applyAdvancedFilters(filters.advInitial)}>
Apply unchanged
</button>
{filters.advApplied != null && (
<WorkOrdersAdvancedFiltersBanner
applied={filters.advApplied}
totalCount={2}
sites={[]}
users={[]}
onClearChip={filters.clearAdvancedFilterChip}
onClearAll={filters.clearAdvancedFilters}
/>
)}
</>
);
}
function searchParams(): Record<string, unknown> | "weekly board" {
const text = screen.getByRole("status", { name: "Board search params" }).textContent ?? "";
return text === "weekly board" ? text : (JSON.parse(text) as Record<string, unknown>);
}
describe("Work Orders exact id filter", () => {
it("opens the board on exactly the linked work orders and shows the selection chip", async () => {
renderWithProviders(<BoardFilterHarness />, {
route: "/workorders?ids=4,9,4",
withAuth: false,
});
await waitFor(() => expect(searchParams()).not.toBe("weekly board"));
const params = searchParams() as Record<string, unknown>;
expect(params.ids).toBe("4,9");
// No saved or default facet travels with the id set.
expect(params.statuses).toBeUndefined();
expect(params.dispatchers).toBeUndefined();
expect(params.types).toBeUndefined();
expect(screen.getByText("Showing 2 selected work orders")).toBeInTheDocument();
// The applied link params are consumed, like the dashboard drill-downs.
expect(screen.getByRole("status", { name: "Current search" })).toHaveTextContent(/^$/);
});
it("returns to the weekly board when the selection chip is cleared", async () => {
const user = userEvent.setup();
renderWithProviders(<BoardFilterHarness />, {
route: buildWorkOrderIdsHref([12]),
withAuth: false,
});
expect(await screen.findByText("Showing 1 selected work order")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Remove filter" }));
expect(searchParams()).toBe("weekly board");
expect(screen.queryByText(/selected work order/)).not.toBeInTheDocument();
});
it("replaces the selection when a panel filter is applied, so no chip shows a filter the server ignores", async () => {
const user = userEvent.setup();
renderWithProviders(<BoardFilterHarness />, {
route: "/workorders?ids=4,9",
withAuth: false,
});
expect(await screen.findByText("Showing 2 selected work orders")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Apply rescheduled only" }));
const params = searchParams() as Record<string, unknown>;
expect(params.ids).toBeUndefined();
expect(params.rescheduled).toBe(true);
expect(screen.queryByText(/selected work order/)).not.toBeInTheDocument();
});
it("keeps the selection when the panel is applied without a filter", async () => {
const user = userEvent.setup();
renderWithProviders(<BoardFilterHarness />, {
route: "/workorders?ids=4,9",
withAuth: false,
});
expect(await screen.findByText("Showing 2 selected work orders")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Apply unchanged" }));
expect((searchParams() as Record<string, unknown>).ids).toBe("4,9");
expect(screen.getByText("Showing 2 selected work orders")).toBeInTheDocument();
});
it("never sends more ids than the server accepts, however the selection was built", () => {
const ids = new Set(Array.from({ length: WORK_ORDER_IDS_MAX + 30 }, (_, index) => index + 1));
const params = buildResolvedBoardSearchParams({
advApplied: { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), workOrderIds: ids },
search: "",
page: 0,
pageSize: 200,
sortBy: "",
sortDir: "",
locations: [],
vendors: [],
});
expect(params?.ids?.split(",")).toHaveLength(WORK_ORDER_IDS_MAX);
});
it("ignores other drill-down params sent alongside the id set", async () => {
renderWithProviders(<BoardFilterHarness />, {
route: "/workorders?ids=5&statuses=Scheduled&dispatchers=u1",
withAuth: false,
});
await waitFor(() => expect(searchParams()).not.toBe("weekly board"));
const params = searchParams() as Record<string, unknown>;
expect(params.ids).toBe("5");
expect(params.statuses).toBeUndefined();
expect(params.dispatchers).toBeUndefined();
});
it("keeps the regular drill-down filters when no ids are given", async () => {
renderWithProviders(<BoardFilterHarness />, {
route: "/workorders?statuses=Scheduled",
withAuth: false,
});
await waitFor(() => expect(searchParams()).not.toBe("weekly board"));
const params = searchParams() as Record<string, unknown>;
expect(params.ids).toBeUndefined();
expect(params.statuses).toEqual([expect.any(Number)]);
});
});

View file

@ -367,7 +367,7 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
expect(unscheduled).toHaveAttribute("aria-pressed", "true");
expect(within(sheet).getByRole("button", { name: /^This week$/i })).toHaveAttribute(
"aria-pressed",
"false",
"true",
);
expect(within(sheet).queryByLabelText(/^From$/i)).not.toBeInTheDocument();
expect(within(sheet).queryByLabelText(/^To$/i)).not.toBeInTheDocument();

View file

@ -1,84 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { UpliftRequestCard } from "@/app/v/[token]/dispatch/_components/uplift-request-card";
import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal";
function request(overrides: Partial<VendorPortalUpliftRequest> = {}): VendorPortalUpliftRequest {
return {
id: 7,
status: "Pending",
requestedNTE: 1500,
currentNTE: 1000,
vendorReason: "Extra parts",
raisedByVendor: true,
...overrides,
};
}
function renderCard(req: VendorPortalUpliftRequest) {
return render(
<UpliftRequestCard
request={req}
locked={false}
canAct
uploadingPercent={undefined}
submitting={false}
errorMessage=""
onWithdraw={vi.fn()}
onRevise={vi.fn()}
onCancelRevise={vi.fn()}
/>,
);
}
describe("UpliftRequestCard actions", () => {
it("offers Withdraw on a pending request the vendor raised", () => {
renderCard(request({ status: "Pending" }));
expect(screen.getByRole("button", { name: /withdraw request/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /revise/i })).not.toBeInTheDocument();
});
it("offers Withdraw and Revise when changes are requested on a request the vendor raised", () => {
renderCard(request({ status: "ChangesRequested" }));
expect(screen.getByRole("button", { name: /withdraw request/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /revise/i })).toBeInTheDocument();
});
it("shows a work-order increase as the new NTE total", () => {
renderCard(request({ raisedByVendor: false, requestedNTE: 90, currentNTE: 600 }));
expect(screen.getByText("Requested").nextElementSibling).toHaveTextContent(
"$690.00 (was $600.00)",
);
});
it("shows a vendor request's requested total unchanged", () => {
renderCard(request({ raisedByVendor: true, requestedNTE: 900, currentNTE: 600 }));
expect(screen.getByText("Requested").nextElementSibling).toHaveTextContent(
"$900.00 (was $600.00)",
);
});
it("treats a request without the raised-by flag as vendor-raised", () => {
renderCard(request({ raisedByVendor: undefined, requestedNTE: 900, currentNTE: 600 }));
expect(screen.getByText("Requested").nextElementSibling).toHaveTextContent(
"$900.00 (was $600.00)",
);
expect(screen.getByRole("button", { name: /withdraw request/i })).toBeInTheDocument();
});
it.each(["Pending", "ChangesRequested"])(
"shows a %s request raised from the work order read-only",
(status) => {
renderCard(request({ status, raisedByVendor: false, requestedNTE: 90, currentNTE: 600 }));
expect(screen.getByText(status)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /withdraw request/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /revise/i })).not.toBeInTheDocument();
},
);
});

View file

@ -1,9 +1,6 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() }));
function renderForm(overrides: Partial<React.ComponentProps<typeof UpliftRequestForm>> = {}) {
return render(
@ -41,70 +38,6 @@ describe("UpliftRequestForm revise mode", () => {
expect(screen.getByText(/must be greater than the current NTE/i)).toBeInTheDocument();
});
it("refuses photo evidence when the work order already has 10 photos", async () => {
renderForm({ mediaUsage: { photos: 10, videos: 0, maxPhotos: 10, maxVideos: 3 } });
fireEvent.change(screen.getByLabelText(/evidence/i), {
target: { files: [new File(["jpg"], "receipt.jpg", { type: "image/jpeg" })] },
});
expect(await screen.findByText("A work order can have at most 10 photos.")).toBeInTheDocument();
});
it("offers and accepts the same phone photo and video types as the upload allowlist", async () => {
renderForm();
const input = screen.getByLabelText("Evidence (PDF, JPG, PNG, HEIC, MP4, MOV)");
expect(input.getAttribute("accept")?.split(",")).toEqual(
expect.arrayContaining([".pdf", ".jpg", ".png", ".heic", ".mp4", ".mov"]),
);
fireEvent.change(input, {
target: { files: [new File(["gif"], "clip.gif", { type: "image/gif" })] },
});
expect(
await screen.findByText("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed."),
).toBeInTheDocument();
fireEvent.change(input, {
target: { files: [new File(["heic"], "site.heic", { type: "" })] },
});
await waitFor(() =>
expect(
screen.queryByText("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed."),
).not.toBeInTheDocument(),
);
});
it("holds submit while a picked video is still being checked, then submits that video", async () => {
let finishProbe: (seconds: number | null) => void = () => {};
vi.mocked(probeVideoDurationSeconds).mockImplementationOnce(
() => new Promise((resolve) => (finishProbe = resolve)),
);
const onSubmit = vi.fn();
renderForm({ initialRequested: "1200", initialReason: "Extra parts", onSubmit });
const video = new File(["mov"], "IMG_0001.MOV", { type: "video/quicktime" });
fireEvent.change(screen.getByLabelText(/evidence/i), { target: { files: [video] } });
const checking = await screen.findByRole("button", { name: "Checking evidence…" });
expect(checking).toBeDisabled();
fireEvent.submit(checking.closest("form")!);
expect(onSubmit).not.toHaveBeenCalled();
expect(screen.queryByText(/approval-required requests need evidence/i)).not.toBeInTheDocument();
finishProbe(30);
const submitButton = await screen.findByRole("button", { name: /submit revision/i });
expect(submitButton).toBeEnabled();
fireEvent.click(submitButton);
expect(onSubmit).toHaveBeenCalledWith({
amount: 1200,
reason: "Extra parts",
evidenceFile: video,
});
});
it("reports the current NTE threshold in the actionable error", () => {
renderForm({ initialRequested: "900", currentNte: 1000 });

View file

@ -4,11 +4,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal";
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() }));
const quarantinedDocument: VendorPortalDocument = {
id: 12,
originalFileName: "completion.pdf",
@ -43,7 +40,7 @@ describe("VendorPortalDocuments", () => {
});
expect(await screen.findByRole("alert")).toHaveTextContent(
"Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.",
"Only PDF, JPG, and PNG files are allowed.",
);
expect(upload).not.toHaveBeenCalled();
});
@ -65,163 +62,6 @@ describe("VendorPortalDocuments", () => {
expect(screen.getByText("Processing")).toBeInTheDocument();
});
it.each([
["animation.gif", "image/gif"],
["clip.webm", "video/webm"],
])("rejects %s (%s), which the server does not accept", async (name, type) => {
const upload = vi.spyOn(vendorPortalApi, "uploadDocument");
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[]}
locked={false}
onChanged={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.change(screen.getByLabelText("Choose completion document"), {
target: { files: [new File(["x"], name, { type })] },
});
expect(await screen.findByRole("alert")).toHaveTextContent(
"Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.",
);
expect(upload).not.toHaveBeenCalled();
});
describe("SH-116 media contract", () => {
const MB = 1_000_000;
const renderDocuments = () =>
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[]}
locked={false}
onChanged={vi.fn().mockResolvedValue(undefined)}
/>,
{ withAuth: false },
);
const choose = (file: File) =>
fireEvent.change(screen.getByLabelText("Choose completion document"), {
target: { files: [file] },
});
it("states the limit for every accepted kind, including PDFs", () => {
renderDocuments();
expect(
screen.getByText(
"Accepted: PDF, JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB · Videos up to 100 MB and 90 s · PDFs up to 10 MB",
),
).toBeInTheDocument();
});
it("uploads a ~60 MB phone MOV whose browser type is empty", async () => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null);
const upload = vi
.spyOn(vendorPortalApi, "uploadDocument")
.mockResolvedValue(quarantinedDocument);
renderDocuments();
const video = { name: "IMG_0001.MOV", type: "", size: 60 * MB } as File;
choose(video);
await waitFor(() => expect(upload).toHaveBeenCalledOnce());
expect(upload.mock.calls[0][0].file).toBe(video);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it.each([
[
{ name: "clip.mp4", type: "video/mp4", size: 101 * MB },
"Videos must be 100 MB or smaller.",
],
[
{ name: "IMG_1.jpg", type: "image/jpeg", size: 11 * MB },
"Photos must be 10 MB or smaller.",
],
])("rejects %o with a generic message", async (file, message) => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
const upload = vi.spyOn(vendorPortalApi, "uploadDocument");
renderDocuments();
choose(file as File);
expect(await screen.findByRole("alert")).toHaveTextContent(message);
expect(upload).not.toHaveBeenCalled();
});
// Three videos already on the work order (dispatcher and vendor uploads together).
const fullCounts = {
maxPhotos: 10,
maxVideos: 3,
photos: 10,
videos: 3,
completionPhotos: 10,
completionVideos: 3,
};
const videoDocument: VendorPortalDocument = {
...quarantinedDocument,
id: 21,
originalFileName: "walkthrough.mp4",
contentType: "video/mp4",
};
it("rejects the 4th video and 11th photo counted across the whole work order", async () => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
const upload = vi.spyOn(vendorPortalApi, "uploadDocument");
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[]}
mediaCounts={fullCounts}
locked={false}
onChanged={vi.fn()}
/>,
{ withAuth: false },
);
choose({ name: "IMG_0004.MOV", type: "video/quicktime", size: 20 * MB } as File);
expect(await screen.findByRole("alert")).toHaveTextContent(
"A work order can have at most 3 videos.",
);
choose({ name: "IMG_0011.jpg", type: "image/jpeg", size: 2 * MB } as File);
await waitFor(() =>
expect(screen.getByRole("alert")).toHaveTextContent(
"A work order can have at most 10 photos.",
),
);
expect(upload).not.toHaveBeenCalled();
});
it("lets a vendor replace one of the counted videos on a full work order", async () => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
const upload = vi.spyOn(vendorPortalApi, "uploadDocument").mockResolvedValue(videoDocument);
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[videoDocument]}
mediaCounts={fullCounts}
locked={false}
onChanged={vi.fn().mockResolvedValue(undefined)}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
const replacement = { name: "walkthrough-v2.mp4", type: "video/mp4", size: 20 * MB } as File;
choose(replacement);
await waitFor(() => expect(upload).toHaveBeenCalledOnce());
expect(upload.mock.calls[0][0]).toMatchObject({ file: replacement, replacesDocumentId: 21 });
});
});
it("reports upload progress and refreshes the dispatch after success", async () => {
const onChanged = vi.fn().mockResolvedValue(undefined);
let finishUpload: (() => void) | undefined;
@ -257,40 +97,4 @@ describe("VendorPortalDocuments", () => {
finishUpload?.();
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
});
it("drops an earlier pick whose video check finishes after a newer pick", async () => {
let finishProbe: ((seconds: number) => void) | undefined;
vi.mocked(probeVideoDurationSeconds).mockImplementationOnce(
() =>
new Promise((resolve) => {
finishProbe = resolve;
}),
);
const upload = vi
.spyOn(vendorPortalApi, "uploadDocument")
.mockResolvedValue(quarantinedDocument);
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[]}
locked={false}
onChanged={vi.fn().mockResolvedValue(undefined)}
/>,
{ withAuth: false },
);
const input = screen.getByLabelText("Choose completion document");
const video = { name: "IMG_0001.MOV", type: "video/quicktime", size: 20_000_000 } as File;
const photo = { name: "IMG_0002.jpg", type: "image/jpeg", size: 2_000_000 } as File;
fireEvent.change(input, { target: { files: [video] } });
await waitFor(() => expect(finishProbe).toBeDefined());
fireEvent.change(input, { target: { files: [photo] } });
await waitFor(() => expect(upload).toHaveBeenCalledOnce());
finishProbe?.(30);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(upload).toHaveBeenCalledOnce();
expect(upload.mock.calls[0][0].file).toBe(photo);
});
});

View file

@ -9,9 +9,8 @@ import { NotificationSessionProvider } from "@/domain/notifications/session/noti
import { renderWithProviders } from "@/test/test-utils";
const apiGet = vi.hoisted(() => vi.fn());
const apiPostNoContent = vi.hoisted(() => vi.fn());
vi.mock("@/api/api", () => ({ apiGet, apiPostNoContent }));
vi.mock("@/api/api", () => ({ apiGet }));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/providers/auth-context")>()),
@ -132,8 +131,6 @@ describe("notification center", () => {
beforeEach(() => {
apiGet.mockReset();
apiGet.mockResolvedValue(standardFeed);
apiPostNoContent.mockReset();
apiPostNoContent.mockResolvedValue(undefined);
});
it("requests the signed-in user's feed and counts every unread item on the bell", async () => {
@ -259,58 +256,6 @@ describe("notification center", () => {
expect(within(panel).getByText(acknowledgeRow.title)).toBeInTheDocument();
});
it("keeps Clear all disabled when only rows that need acknowledging are left", async () => {
apiGet.mockResolvedValue(feed(section("sla", "SLA at Risk", [acknowledgeRow])));
const user = userEvent.setup();
renderBell();
const panel = await openPanel(user);
expect(within(panel).getByRole("button", { name: "Clear all" })).toBeDisabled();
});
it("acknowledges a missed deadline through the API and removes the row", async () => {
apiGet.mockResolvedValue(
feed(
section("sla", "SLA at Risk", [acknowledgeRow]),
section("unassigned", "Unassigned", [unassigned]),
),
);
const user = userEvent.setup();
renderBell();
const panel = await openPanel(user);
const slaRow = within(panel).getByText(acknowledgeRow.title).closest("li") as HTMLElement;
const acknowledge = within(slaRow).getByRole("button", { name: "Acknowledge SLA breach" });
expect(acknowledge).toHaveAttribute("title", "Acknowledge");
expect(acknowledge).toHaveTextContent("Acknowledge");
apiGet.mockResolvedValue(feed(section("unassigned", "Unassigned", [unassigned])));
await user.click(acknowledge);
expect(apiPostNoContent).toHaveBeenCalledWith("notifications/sla/9/acknowledge");
await waitFor(() =>
expect(within(panel).queryByText(acknowledgeRow.title)).not.toBeInTheDocument(),
);
expect(sectionLabels(panel)).toEqual(["Unassigned, 3"]);
});
it("keeps the row when the acknowledgement fails", async () => {
apiGet.mockResolvedValue(feed(section("sla", "SLA at Risk", [acknowledgeRow])));
apiPostNoContent.mockRejectedValue(new Error("boom"));
const user = userEvent.setup();
renderBell();
const panel = await openPanel(user);
await user.click(within(panel).getByRole("button", { name: "Acknowledge SLA breach" }));
await waitFor(() => expect(apiPostNoContent).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(within(panel).getByRole("button", { name: "Acknowledge SLA breach" })).toBeEnabled(),
);
expect(within(panel).getByText(acknowledgeRow.title)).toBeInTheDocument();
});
it("opens the unassigned queue with the existing board filters and closes the panel", async () => {
const user = userEvent.setup();
renderBell();

View file

@ -1,148 +0,0 @@
import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useLocation } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SlaAtRiskBanner } from "@/components/notifications/sla-at-risk-banner";
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
import { queryKeys } from "@/infra/query-key/query-key";
import { renderWithProviders } from "@/test/test-utils";
const apiGet = vi.hoisted(() => vi.fn());
const toastWarn = vi.hoisted(() => vi.fn());
vi.mock("@/api/api", () => ({ apiGet }));
vi.mock("react-toastify", () => ({ toast: { warn: toastWarn } }));
const BANNER = "Work orders at risk of missing their response deadline";
function atRisk(id: number, number: string | null = String(1000 + id)) {
return {
id,
number,
severity: 1,
startedAt: "2026-09-18T10:00:00Z",
deadlineAt: "2026-09-18T12:00:00Z",
percentElapsed: 60,
};
}
function feed(...slaAtRisk: ReturnType<typeof atRisk>[]) {
return { generatedAt: "2026-09-18T11:12:00Z", sections: [], slaAtRisk };
}
function LocationProbe() {
const location = useLocation();
return <output aria-label="Current location">{`${location.pathname}${location.search}`}</output>;
}
function renderBanner() {
return renderWithProviders(
<NotificationSessionProvider>
<SlaAtRiskBanner />
<LocationProbe />
</NotificationSessionProvider>,
{ withAuth: false },
);
}
describe("SLA at-risk banner", () => {
beforeEach(() => {
apiGet.mockReset();
toastWarn.mockReset();
});
it("stays hidden while no work order is past half of its response window", async () => {
apiGet.mockResolvedValue(feed());
renderBanner();
await waitFor(() => expect(apiGet).toHaveBeenCalledWith("notifications"));
expect(screen.queryByRole("region", { name: BANNER })).not.toBeInTheDocument();
expect(toastWarn).not.toHaveBeenCalled();
});
it("names the single work order at risk", async () => {
apiGet.mockResolvedValue(feed(atRisk(101)));
renderBanner();
const banner = await screen.findByRole("region", { name: BANNER });
expect(
within(banner).getByRole("button", {
name: "WO #1101 is at risk of missing its response deadline",
}),
).toBeInTheDocument();
});
it("collapses several work orders to one line and opens Work Orders on exactly those", async () => {
apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205), atRisk(318)));
const user = userEvent.setup();
renderBanner();
const banner = await screen.findByRole("region", { name: BANNER });
const open = within(banner).getByRole("button", {
name: "3 work orders are at risk of missing their response deadline",
});
expect(open).toHaveAttribute("title", "View in Work Orders");
expect(within(banner).getAllByRole("button")).toHaveLength(2);
await user.click(open);
expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent(
"/workorders?ids=101,205,318",
);
});
it("dismisses for this session only and returns when a new work order becomes at risk", async () => {
apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205)));
const user = userEvent.setup();
const { queryClient, unmount } = renderBanner();
const banner = await screen.findByRole("region", { name: BANNER });
await user.click(within(banner).getByRole("button", { name: "Dismiss SLA alert" }));
expect(screen.queryByRole("region", { name: BANNER })).not.toBeInTheDocument();
apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205), atRisk(318)));
await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }));
const returned = await screen.findByRole("region", { name: BANNER });
expect(
within(returned).getByRole("button", {
name: "WO #1318 is at risk of missing its response deadline",
}),
).toBeInTheDocument();
unmount();
renderBanner();
expect(
await screen.findByRole("button", {
name: "3 work orders are at risk of missing their response deadline",
}),
).toBeInTheDocument();
});
it("toasts each work order once per session, however often the feed polls", async () => {
apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205, null)));
const { queryClient } = renderBanner();
await screen.findByRole("region", { name: BANNER });
expect(toastWarn).toHaveBeenCalledTimes(2);
expect(toastWarn).toHaveBeenCalledWith("WO #1101 is at 50% of its SLA response window", {
toastId: "sla-at-risk-101",
});
expect(toastWarn).toHaveBeenCalledWith("WO 205 is at 50% of its SLA response window", {
toastId: "sla-at-risk-205",
});
apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205, null), atRisk(318)));
await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }));
await screen.findByRole("button", {
name: "3 work orders are at risk of missing their response deadline",
});
await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }));
expect(toastWarn).toHaveBeenCalledTimes(3);
expect(toastWarn).toHaveBeenLastCalledWith("WO #1318 is at 50% of its SLA response window", {
toastId: "sla-at-risk-318",
});
});
});

View file

@ -27,7 +27,6 @@ describe("dashboardApi", () => {
ScheduledTomorrow: "3",
PendingUplifts: 2,
AvetaPending: "1",
Unassigned: "6",
Breakdown: [
{ Status: "Scheduled", Count: "7" },
{ Status: "Completed", Count: 4 },
@ -53,7 +52,6 @@ describe("dashboardApi", () => {
scheduledTomorrow: 3,
pendingUplifts: 2,
avetaPending: 1,
unassigned: 6,
breakdown: [
{ status: "Scheduled", count: 7 },
{ status: "Completed", count: 4 },

View file

@ -1,69 +0,0 @@
import { describe, expect, it } from "vitest";
import type {
NotificationFeed,
NotificationItem,
} from "@/domain/notifications/types/notification-feed";
import { withoutSlaBreach } from "@/domain/notifications/use-cases/use-acknowledge-sla-breach";
function row(
overrides: Partial<NotificationItem> & Pick<NotificationItem, "id">,
): NotificationItem {
return {
reason: "sla",
severity: "Critical",
rowType: "acknowledge",
title: "missed its response deadline",
count: 1,
triggeredAt: "2026-09-18T12:00:00Z",
target: { kind: "workOrder", workOrderId: 9, tab: "info" },
workOrders: [],
...overrides,
};
}
const breach9 = row({ id: "sla-breach-9" });
const breach10 = row({ id: "sla-breach-10", target: { kind: "workOrder", workOrderId: 10 } });
const atRisk9 = row({ id: "sla-at-risk-9", severity: "High", rowType: "dismissable" });
const aveta9 = row({ id: "aveta-missing-9", reason: "aveta-missing", rowType: "dismissable" });
const feed: NotificationFeed = {
generatedAt: "2026-09-18T12:00:00Z",
slaAtRisk: [],
sections: [
{
reason: "sla",
label: "SLA at Risk",
severity: "Critical",
count: 3,
items: [breach9, breach10, atRisk9],
},
{
reason: "aveta-missing",
label: "Aveta Missing",
severity: "Medium",
count: 1,
items: [aveta9],
},
],
};
describe("withoutSlaBreach", () => {
it("drops only the acknowledged work order's breach row and lowers its section count", () => {
const next = withoutSlaBreach(feed, 9);
expect(next.sections[0].items.map((item) => item.id)).toEqual([
"sla-breach-10",
"sla-at-risk-9",
]);
expect(next.sections[0].count).toBe(2);
expect(next.sections[1]).toBe(feed.sections[1]);
});
it("leaves the feed as it was when that work order has no breach row", () => {
const next = withoutSlaBreach(feed, 42);
expect(next.sections).toEqual(feed.sections);
expect(next.sections[0]).toBe(feed.sections[0]);
});
});

View file

@ -420,14 +420,6 @@ describe("workOrdersApi.searchBoard", () => {
});
});
/** A WO# lookup spans every week and undated rows. */
const ALL_WEEKS_SEARCH = {
datePreset: "Custom",
dateFrom: "2000-01-01",
dateTo: "2099-12-31",
includeDateless: "true",
};
describe("workOrdersApi.findByWoNumber", () => {
beforeEach(() => {
apiGet.mockReset();
@ -458,7 +450,6 @@ describe("workOrdersApi.findByWoNumber", () => {
const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams;
expect(Object.fromEntries(searchParams)).toEqual({
search: "10000001235",
...ALL_WEEKS_SEARCH,
page: "0",
pageSize: "100",
});
@ -528,7 +519,6 @@ describe("workOrdersApi.findByWoNumber", () => {
const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams;
expect(Object.fromEntries(searchParams)).toEqual({
search: "9",
...ALL_WEEKS_SEARCH,
page: "0",
pageSize: "100",
});

View file

@ -1,13 +1,6 @@
import { describe, expect, it } from "vitest";
import { parseDashboardDrilldownParams } from "@/domain/work-orders/utils/dashboard-drilldown-params";
import {
avetaPendingDrilldownSearch,
unassignedDrilldownSearch,
} from "@/domain/dashboard/utils/work-order-drilldown-links";
import { NON_TERMINAL_LIFECYCLE_STATUSES } from "@/domain/work-orders/types/work-order-board";
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import { buildAdvancedFilterChips } from "@/domain/work-orders/utils/advanced-filters-utils";
import { toBoardSearchQueryParams } from "@/domain/work-orders/utils/board-query-params";
import { avetaPendingDrilldownSearch } from "@/domain/dashboard/utils/work-order-drilldown-links";
describe("parseDashboardDrilldownParams", () => {
it("returns null when no recognized param is present", () => {
@ -67,41 +60,6 @@ describe("parseDashboardDrilldownParams", () => {
expect(filters?.regions).toEqual(new Set(["California"]));
});
it("turns the Unassigned tile link into the board search the Dashboard counts (SH-392)", () => {
const allTime = parseDashboardDrilldownParams(unassignedDrilldownSearch("all-time"));
expect(allTime).not.toBeNull();
expect(toBoardSearchQueryParams({ filters: allTime! })).toMatchObject({
dispatchers: ["__unassigned__"],
statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES],
datePreset: "Custom",
dateFrom: "2000-01-01",
dateTo: "2099-12-31",
includeDateless: true,
});
const week = parseDashboardDrilldownParams(unassignedDrilldownSearch("this-week"));
const weekParams = toBoardSearchQueryParams({ filters: week! });
expect(week?.dateRange).toBe("custom");
expect(weekParams).toMatchObject({
dispatchers: ["__unassigned__"],
statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES],
datePreset: "Custom",
});
expect(weekParams.includeDateless).toBeUndefined();
});
it("opens the Unassigned tile link as the Unassigned option of Assigned to", () => {
const filters = parseDashboardDrilldownParams(unassignedDrilldownSearch("this-week"));
expect(filters?.dispatchers).toEqual(new Set([ASSIGNEE_FILTER_UNASSIGNED]));
const chips = buildAdvancedFilterChips(filters!, { dispatcherNameOf: (id) => id });
const assignedTo = chips.filter((chip) => chip.group === "Assigned to");
expect(assignedTo.map((chip) => chip.value)).toEqual(["Unassigned"]);
const cleared = assignedTo[0]!.clear(filters!);
expect(cleared.dispatchers.size).toBe(0);
});
it("rejects partial or malformed date ranges", () => {
expect(
parseDashboardDrilldownParams(

View file

@ -40,12 +40,12 @@ describe("countAdvancedFilters", () => {
expect(countAdvancedFilters(filters)).toBe(3);
});
it("counts Unscheduled +1 with no date range selected", () => {
it("counts Unscheduled +1 with the this-week default still in place", () => {
const filters = {
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
unscheduledOnly: true,
};
expect(filters.dateRange).toBeNull();
expect(filters.dateRange).toBe("this-week");
expect(countAdvancedFilters(filters)).toBe(1);
});

View file

@ -130,43 +130,9 @@ describe("toBoardSearchQueryParams", () => {
customTo: "2026-06-30",
},
});
expect(thisWeek.datePreset).toBe("ThisWeek");
expect(thisWeek.dateFrom).toBeUndefined();
expect(thisWeek.dateTo).toBeUndefined();
});
it("narrows strictly for every selected range, This week included", () => {
for (const dateRange of ["this-week", "last-week", "last-3-months", "custom"] as const) {
const params = toBoardSearchQueryParams({
filters: {
...EMPTY_ADVANCED_FILTERS,
dateRange,
customFrom: "2026-09-21",
customTo: "2026-09-25",
dispatchers: new Set(["__unassigned__"]),
},
});
expect(params.includeDateless).toBeUndefined();
expect(params.dateFrom).not.toBe("2000-01-01");
}
});
it("searches every week, undated rows included, when no range is selected", () => {
const params = toBoardSearchQueryParams({
filters: { ...EMPTY_ADVANCED_FILTERS, dispatchers: new Set(["__unassigned__"]) },
});
expect(EMPTY_ADVANCED_FILTERS.dateRange).toBeNull();
expect(params).toMatchObject({
datePreset: "Custom",
dateFrom: "2000-01-01",
dateTo: "2099-12-31",
includeDateless: true,
dispatchers: ["__unassigned__"],
});
});
it("keeps undated rows in the pinned Unassigned queue", () => {
expect(toUnassignedQueueQueryParams().includeDateless).toBe(true);
expect(thisWeek.datePreset).toBe("Custom");
expect(thisWeek.dateFrom).toBe("2000-01-01");
expect(thisWeek.dateTo).toBe("2099-12-31");
});
it("sends unscheduledOnly=true with no faked Custom date window (SH-338)", () => {

View file

@ -454,56 +454,6 @@ describe("matchesAdvancedFilters", () => {
expect(matchesAdvancedFilters(weekOnly, filters)).toBe(true);
expect(matchesAdvancedFilters(undatedAssigned, filters)).toBe(true);
});
it("matches a range the same way the board search does", () => {
const filters = {
...EMPTY_ADVANCED_FILTERS,
dateRange: "custom" as const,
customFrom: "2026-09-23",
customTo: "2026-09-25",
};
// Week-only: a target week overlapping the range matches, whatever date the
// row still carries.
const weekOnlyStaleDate = makeRow({
scheduledOn: "2026-10-02",
targetWeek: "2026-09-21",
scheduleWeekOnly: true,
});
const weekOnlyPreviousWeek = makeRow({
scheduledOn: "",
targetWeek: "2026-09-14",
scheduleWeekOnly: true,
});
const weekOnlyOutside = makeRow({
scheduledOn: "",
targetWeek: "2026-09-28",
scheduleWeekOnly: true,
});
const datedOutsideWithWeek = makeRow({ scheduledOn: "2026-10-02", targetWeek: "2026-09-21" });
const undated = makeRow({ scheduledOn: "", targetWeek: "" });
expect(matchesAdvancedFilters(weekOnlyStaleDate, filters)).toBe(true);
expect(matchesAdvancedFilters(weekOnlyPreviousWeek, filters)).toBe(false);
expect(matchesAdvancedFilters(weekOnlyOutside, filters)).toBe(false);
expect(matchesAdvancedFilters(datedOutsideWithWeek, filters)).toBe(false);
expect(matchesAdvancedFilters(undated, filters)).toBe(false);
// No range: every week, plus open undated rows. Closed undated rows are
// never unscheduled, with or without the Unscheduled filter.
const noRange = { ...filters, dateRange: null };
const unscheduledOnly = { ...filters, unscheduledOnly: true };
const completedUndated = makeRow({ scheduledOn: "", targetWeek: "", status: "Completed" });
const canceledUndated = makeRow({ scheduledOn: "", targetWeek: "", status: "Canceled" });
expect(matchesAdvancedFilters(undated, noRange)).toBe(true);
expect(matchesAdvancedFilters(datedOutsideWithWeek, noRange)).toBe(true);
expect(matchesAdvancedFilters(weekOnlyOutside, noRange)).toBe(true);
expect(matchesAdvancedFilters(completedUndated, noRange)).toBe(false);
expect(matchesAdvancedFilters(canceledUndated, noRange)).toBe(false);
expect(matchesAdvancedFilters(undated, unscheduledOnly)).toBe(true);
expect(matchesAdvancedFilters(completedUndated, unscheduledOnly)).toBe(false);
expect(matchesAdvancedFilters(canceledUndated, unscheduledOnly)).toBe(false);
});
});
describe("computeIsPastDue", () => {

View file

@ -1,33 +0,0 @@
import { describe, expect, it } from "vitest";
import {
WORK_ORDER_IDS_MAX,
buildWorkOrderIdsHref,
normalizeWorkOrderIds,
parseWorkOrderIdsParam,
} from "@/domain/work-orders/utils/work-order-ids-filter";
describe("work-order ids filter", () => {
it("builds the Work Orders link for an exact id set", () => {
expect(buildWorkOrderIdsHref([101, 205, 318])).toBe("/workorders?ids=101,205,318");
});
it("drops duplicates and non-positive ids from the link, and links the plain board when none remain", () => {
expect(buildWorkOrderIdsHref([205, 101, 205, 0, -4, 1.5])).toBe("/workorders?ids=205,101");
expect(buildWorkOrderIdsHref([])).toBe("/workorders");
});
it("reads the ids param, ignoring malformed tokens", () => {
expect(parseWorkOrderIdsParam(" 7, 3 ,abc,,7,-2,0,4.5,12 ")).toEqual([7, 3, 12]);
expect(parseWorkOrderIdsParam(null)).toEqual([]);
expect(parseWorkOrderIdsParam("")).toEqual([]);
});
it("keeps at most the backend limit of distinct ids", () => {
const ids = Array.from({ length: WORK_ORDER_IDS_MAX + 20 }, (_, index) => index + 1);
const normalized = normalizeWorkOrderIds([...ids, 1, 2]);
expect(normalized).toHaveLength(WORK_ORDER_IDS_MAX);
expect(normalized[0]).toBe(1);
expect(normalized[normalized.length - 1]).toBe(WORK_ORDER_IDS_MAX);
});
});

View file

@ -1,31 +0,0 @@
import { describe, expect, it } from "vitest";
import {
boardMediaItemsToMediaFiles,
mapBoardDetailMediaItem,
} from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import { countWorkOrderMediaKinds } from "@/domain/work-orders/utils/work-order-media-limits";
import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils";
describe("photo/video counting for the per-work-order limit", () => {
it("counts a persisted HEIC photo after a refresh", () => {
const heic = mapBoardDetailMediaItem({
id: 41,
category: 3,
url: "https://cdn.example.com/IMG_0042.HEIC",
fileName: "IMG_0042.HEIC",
});
const files = boardMediaItemsToMediaFiles([heic]);
expect(files[0]?.fileType).toBe("image");
expect(countWorkOrderMediaKinds(files)).toEqual({ photos: 1, videos: 0 });
});
it.each([
[{ name: "IMG_0043.HEIC", type: "" }, "image"],
[{ name: "IMG_0044.MOV", type: "" }, "video"],
[{ name: "IMG_0045.jpg", type: "application/octet-stream" }, "image"],
[{ name: "report.docx", type: "" }, "other"],
])("classifies a local %o by extension when the browser type is missing", (file, fileType) => {
expect(classifyUpload({ ...file, size: 1 } as File).fileType).toBe(fileType);
});
});

View file

@ -1,20 +1,14 @@
import { describe, expect, it } from "vitest";
import {
canDeleteWorkOrderMedia,
countWorkOrderMediaKinds,
formatMediaSizeLimit,
isWorkOrderDocumentsReadOnly,
isWorkOrderExtraDocTypeAllowed,
isWorkOrderFileTooLarge,
isWorkOrderMediaTypeAllowed,
MAX_PHOTOS_PER_WORK_ORDER,
MAX_VIDEO_DURATION_SECONDS,
MAX_VIDEOS_PER_WORK_ORDER,
MAX_WORK_ORDER_COMPLETION_DOC_BYTES,
MAX_WORK_ORDER_PHOTO_BYTES,
MAX_WORK_ORDER_VIDEO_BYTES,
MAX_WORK_ORDER_MEDIA_BYTES,
workOrderExtraDocsAcceptAttribute,
workOrderFileSizeRejection,
workOrderMediaAcceptAttribute,
} from "@/domain/work-orders/utils/work-order-media-limits";
@ -31,53 +25,12 @@ describe("work-order-media-limits", () => {
expect(canDeleteWorkOrderMedia({ id: "0" })).toBe(false);
});
it("applies the SH-116 per-kind caps with stable generic messages", () => {
const file = (name: string, type: string, size: number) => ({ name, type, size }) as File;
expect(MAX_WORK_ORDER_PHOTO_BYTES).toBe(10_000_000);
expect(MAX_WORK_ORDER_VIDEO_BYTES).toBe(100_000_000);
expect(MAX_VIDEO_DURATION_SECONDS).toBe(90);
expect(MAX_PHOTOS_PER_WORK_ORDER).toBe(10);
expect(MAX_VIDEOS_PER_WORK_ORDER).toBe(3);
expect(workOrderFileSizeRejection(file("clip.mp4", "video/mp4", 60_000_000))).toBeNull();
expect(workOrderFileSizeRejection(file("IMG_1.MOV", "video/quicktime", 60_000_000))).toBeNull();
expect(workOrderFileSizeRejection(file("IMG_1.MOV", "", 60_000_000))).toBeNull();
expect(
workOrderFileSizeRejection(file("clip.mp4", "application/octet-stream", 60_000_000)),
).toBeNull();
expect(workOrderFileSizeRejection(file("clip.mp4", "video/mp4", 101_000_000))).toBe(
"Videos must be 100 MB or smaller.",
);
expect(workOrderFileSizeRejection(file("IMG_2.jpg", "image/jpeg", 11_000_000))).toBe(
"Photos must be 10 MB or smaller.",
);
expect(workOrderFileSizeRejection(file("IMG_2.HEIC", "", 11_000_000))).toBe(
"Photos must be 10 MB or smaller.",
);
// A foreign declared type never widens the class: this .jpg is still sized as a photo.
expect(workOrderFileSizeRejection(file("IMG_3.jpg", "video/3gpp", 60_000_000))).toBe(
"Photos must be 10 MB or smaller.",
);
expect(workOrderFileSizeRejection(file("scope.pdf", "application/pdf", 51_000_000))).toBe(
"Documents must be 50 MB or smaller.",
);
expect(isWorkOrderFileTooLarge(file("clip.mp4", "video/mp4", MAX_WORK_ORDER_VIDEO_BYTES))).toBe(
false,
);
expect(formatMediaSizeLimit(MAX_WORK_ORDER_VIDEO_BYTES)).toBe("100 MB");
});
it("counts only non-legacy, non-failed photos and videos toward the per-work-order limits", () => {
expect(
countWorkOrderMediaKinds([
{ fileType: "image" },
{ fileType: "image", isLegacy: true },
{ fileType: "video" },
{ fileType: "document" },
{ fileType: "video", uploadStatus: "failed" },
]),
).toEqual({ photos: 1, videos: 1 });
it("caps uploads at 200_000_000 bytes and formats decimal MB", () => {
expect(MAX_WORK_ORDER_MEDIA_BYTES).toBe(200_000_000);
expect(formatMediaSizeLimit()).toBe("200 MB");
expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES } as File)).toBe(false);
expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES + 1 } as File)).toBe(true);
expect(isWorkOrderFileTooLarge({ size: 10 } as File)).toBe(false);
});
it("caps completion-document uploads at 50_000_000 bytes and formats the completion copy", () => {

View file

@ -1,93 +0,0 @@
import { describe, expect, it } from "vitest";
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
function concat(...parts: Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
function ascii(text: string): Uint8Array {
return Uint8Array.from(text, (char) => char.charCodeAt(0));
}
function box(type: string, ...children: Uint8Array[]): Uint8Array {
const body = concat(...children);
const out = new Uint8Array(8 + body.length);
new DataView(out.buffer).setUint32(0, out.length);
out.set(ascii(type), 4);
out.set(body, 8);
return out;
}
function movieHeader(timescale: number, duration: bigint, version1 = false): Uint8Array {
const body = new Uint8Array(version1 ? 32 : 20);
const view = new DataView(body.buffer);
body[0] = version1 ? 1 : 0;
if (version1) {
view.setUint32(20, timescale);
view.setBigUint64(24, duration);
} else {
view.setUint32(12, timescale);
view.setUint32(16, Number(duration));
}
return box("mvhd", body);
}
/** A phone-style file: ftyp, a large mdat, then moov at the end (not fast-start). */
function mp4(timescale: number, duration: bigint, version1 = false): Uint8Array {
return concat(
box("ftyp", ascii("isom"), new Uint8Array(4)),
box("mdat", new Uint8Array(4096)),
box("moov", movieHeader(timescale, duration, version1)),
);
}
function asFile(bytes: Uint8Array, name = "IMG_0001.MOV"): File {
return new File([new Uint8Array(bytes)], name, { type: "video/quicktime" });
}
describe("probeVideoDurationSeconds", () => {
it("reads the duration from a moov box after the media data", async () => {
await expect(probeVideoDurationSeconds(asFile(mp4(600, 57_000n)))).resolves.toBe(95);
});
it("reads a version 1 movie header", async () => {
await expect(probeVideoDurationSeconds(asFile(mp4(1000, 30_500n, true)))).resolves.toBe(30.5);
});
it("follows 64-bit box sizes", async () => {
const mdat = new Uint8Array(16 + 512);
const view = new DataView(mdat.buffer);
view.setUint32(0, 1);
mdat.set(ascii("mdat"), 4);
view.setBigUint64(8, BigInt(mdat.length));
const file = concat(
box("ftyp", ascii("qt "), new Uint8Array(4)),
mdat,
box("moov", movieHeader(90_000, 90_000n * 120n)),
);
await expect(probeVideoDurationSeconds(asFile(file))).resolves.toBe(120);
});
it.each([
[
"no moov",
concat(box("ftyp", ascii("isom"), new Uint8Array(4)), box("mdat", new Uint8Array(64))),
],
["truncated", mp4(600, 57_000n).slice(0, -10)],
["zero timescale", mp4(0, 57_000n)],
[
"oversized box",
concat(box("ftyp", new Uint8Array(8)), Uint8Array.of(0x7f, 0xff, 0xff, 0xff), ascii("moov")),
],
["not a video", ascii("%PDF-1.7 not a movie")],
])("returns null for an unreadable file (%s)", async (_shape, bytes) => {
await expect(probeVideoDurationSeconds(asFile(bytes))).resolves.toBeNull();
});
});