Merge branch 'dev' into fix/ab/sh-261-sh-265-vendor-directory
|
|
@ -12,11 +12,22 @@ Frontend integration contract for weekly board and advanced search filters.
|
||||||
|
|
||||||
Paths no FE: `API_PATHS.workOrder.board`, `boardSearch`, `lookupsDispatchers`.
|
Paths no FE: `API_PATHS.workOrder.board`, `boardSearch`, `lookupsDispatchers`.
|
||||||
|
|
||||||
|
## Contrato (board semanal vs search)
|
||||||
|
|
||||||
|
| Superfície | Endpoint | O que volta |
|
||||||
|
| ------------------------------ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| Week + Day (AAP) | `GET /board?weekStart&weekEnd` | Só WOs com data (ou `ScheduleWeekOnly` + `TargetWeek`) **nessa semana**. `unscheduled: []`. Sem `page`/`pageSize`. |
|
||||||
|
| Contagens do board | `counts.total` / `counts.returned` | Scheduled da semana (antes/depois do search da barra). Nunca `0` se há rows. |
|
||||||
|
| Lista flat | `GET /board/search` | Envelope paginado. `page` 0-based. `pageSize` default 100, **max 200**. |
|
||||||
|
| Fila sem data / sem dispatcher | `GET /board/search` | Filtros (`dispatchers=__unassigned__`, statuses, janela Custom). **Não** entra no `/board`. |
|
||||||
|
|
||||||
|
O AAP não tem pin Unassigned na tabela. Dated sem assignee entram no dia. Undated só em Advanced Filters / filtro Dispatcher.
|
||||||
|
|
||||||
## Quando usar qual endpoint
|
## Quando usar qual endpoint
|
||||||
|
|
||||||
| Modo FE | Endpoint | Quando |
|
| Modo FE | Endpoint | Quando |
|
||||||
| --------------------------------------- | -------------------------- | ------------------------------------------------ |
|
| --------------------------------------- | -------------------------- | ------------------------------------------------ |
|
||||||
| Barra principal (`advApplied === null`) | `GET /board` | Semana + dispatchers + tipo segment + search |
|
| Barra principal (`advApplied === null`) | `GET /board` | Semana agendada + dispatchers + tipo + search |
|
||||||
| Filtros avançados aplicados | `GET /board/search` | Todos os filtros avançados + paginação/ordenação |
|
| Filtros avançados aplicados | `GET /board/search` | Todos os filtros avançados + paginação/ordenação |
|
||||||
| Lookup dispatchers | `GET /lookups/dispatchers` | Popular multi-select de dispatchers |
|
| Lookup dispatchers | `GET /lookups/dispatchers` | Popular multi-select de dispatchers |
|
||||||
|
|
||||||
|
|
@ -43,7 +54,7 @@ Paths no FE: `API_PATHS.workOrder.board`, `boardSearch`, `lookupsDispatchers`.
|
||||||
| FE state | API param | Regra |
|
| FE state | API param | Regra |
|
||||||
| ---------------- | --------------------- | ------------------------------------------- |
|
| ---------------- | --------------------- | ------------------------------------------- |
|
||||||
| `page` | `page` | **0-based** (primeira página = `0`) |
|
| `page` | `page` | **0-based** (primeira página = `0`) |
|
||||||
| `pageSize` | `pageSize` | Default `100`, max `100` |
|
| `pageSize` | `pageSize` | Default `100`, max `200` |
|
||||||
| `sortBy` | `sortBy` | `scheduledDate` \| `woNumber` \| `dueDate` |
|
| `sortBy` | `sortBy` | `scheduledDate` \| `woNumber` \| `dueDate` |
|
||||||
| `sortDir` | `sortDir` | `asc` \| `desc` |
|
| `sortDir` | `sortDir` | `asc` \| `desc` |
|
||||||
| `search` | `search` | `>= 2` chars |
|
| `search` | `search` | `>= 2` chars |
|
||||||
|
|
@ -99,7 +110,9 @@ These facet keys are handled on `GET /board/search` in `shoc-backend` `origin/de
|
||||||
|
|
||||||
- `IsAddOn` is owned by migration `20260730150000_WoIsAddOn` (legacy type-7 backfill).
|
- `IsAddOn` is owned by migration `20260730150000_WoIsAddOn` (legacy type-7 backfill).
|
||||||
- `20260813193000_SH121_BoardSearchFacets` is a **no-op** so both PRs can land without a duplicate column.
|
- `20260813193000_SH121_BoardSearchFacets` is a **no-op** so both PRs can land without a duplicate column.
|
||||||
- Pagination matches this contract: `page` is 0-based; `pageSize` default/max is 100.
|
- Pagination matches this contract: `page` is 0-based; `pageSize` default is 100, max is 200.
|
||||||
|
- `GET /board` returns only scheduled-in-week rows. `unscheduled` is always `[]`. Undated / global Unassigned live on `GET /board/search`.
|
||||||
|
- `counts.total` / `counts.returned` are scheduled-in-week (before/after the bar search).
|
||||||
- Aveta (`avetaOnly`) remains omitted until `avetaRequired` is confirmed.
|
- Aveta (`avetaOnly`) remains omitted until `avetaRequired` is confirmed.
|
||||||
|
|
||||||
### `datePreset`
|
### `datePreset`
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 187 KiB |
|
|
@ -99,6 +99,25 @@ test.describe("Work Orders advanced filters", () => {
|
||||||
await expect(page.getByText("No work orders match these filters")).toBeVisible();
|
await expect(page.getByText("No work orders match these filters")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("sends avetaOnly on board/search and shows empty copy", async ({ page }) => {
|
||||||
|
const searchUrls: string[] = [];
|
||||||
|
await signInAndOpenWorkOrders(page);
|
||||||
|
await mockWorkOrderApis(page, searchUrls);
|
||||||
|
|
||||||
|
await page.goto("/workorders");
|
||||||
|
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sheet = await applyAdvancedFilters(page);
|
||||||
|
await sheet.getByRole("button", { name: /Aveta Compliance/i }).click();
|
||||||
|
await sheet.getByRole("checkbox", { name: /Aveta required only/i }).click();
|
||||||
|
await sheet.getByRole("button", { name: /apply filters/i }).click();
|
||||||
|
|
||||||
|
await expect.poll(() => searchUrls.some((url) => url.includes("avetaOnly=true"))).toBe(true);
|
||||||
|
await expect(page.getByText("No work orders match these filters")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("sends combined advanced-filter facets on the same board/search request", async ({
|
test("sends combined advanced-filter facets on the same board/search request", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,18 @@ function boardRow(id: number, woNumber: string, scheduledDate: string) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function hideQueryDevtools(page: Page) {
|
||||||
|
await page.addStyleTag({
|
||||||
|
content:
|
||||||
|
".tsqd-parent-container { display: none !important; pointer-events: none !important; }",
|
||||||
|
});
|
||||||
|
await page.locator(".tsqd-parent-container").evaluateAll((nodes) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
node.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function mockWorkOrderBoard(page: Page) {
|
async function mockWorkOrderBoard(page: Page) {
|
||||||
const wednesday = Array.from({ length: 60 }, (_, index) =>
|
const wednesday = Array.from({ length: 60 }, (_, index) =>
|
||||||
boardRow(1000 + index, `WO-${1000 + index}`, "2026-08-19"),
|
boardRow(1000 + index, `WO-${1000 + index}`, "2026-08-19"),
|
||||||
|
|
@ -43,7 +55,15 @@ async function mockWorkOrderBoard(page: Page) {
|
||||||
async (route) => {
|
async (route) => {
|
||||||
const url = route.request().url();
|
const url = route.request().url();
|
||||||
if (url.includes("workorders/board/search")) {
|
if (url.includes("workorders/board/search")) {
|
||||||
await fulfillJson(route, { items: [], totalCount: 0, page: 1, pageSize: 100 });
|
const page = Number(new URL(url).searchParams.get("page") ?? "0");
|
||||||
|
const pageSize = Number(new URL(url).searchParams.get("pageSize") ?? "50");
|
||||||
|
const items = wednesday.slice(page * pageSize, page * pageSize + pageSize);
|
||||||
|
await fulfillJson(route, {
|
||||||
|
items,
|
||||||
|
totalCount: 1778,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (url.includes("workorders/board") && route.request().method() === "GET") {
|
if (url.includes("workorders/board") && route.request().method() === "GET") {
|
||||||
|
|
@ -79,44 +99,48 @@ test.describe("work order per-day pagination (SH-241)", () => {
|
||||||
await mockWorkOrderBoard(page);
|
await mockWorkOrderBoard(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("changes rows-per-page, hides on global filter, and resets on day or week change", async ({
|
test("changes rows-per-page, keeps search pager on advanced filters, and resets on day or week change", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const pager = page.getByTestId("wo-day-pagination");
|
const pager = page.getByTestId("wo-day-pagination");
|
||||||
await page.goto("/workorders");
|
await page.goto("/workorders");
|
||||||
|
await hideQueryDevtools(page);
|
||||||
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
|
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
});
|
});
|
||||||
await expect(pager).toBeVisible();
|
await expect(pager).toBeVisible();
|
||||||
await expect(page.getByText("Page 1 of 2")).toBeVisible();
|
await expect(pager.getByText("Page 1 of 2")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("wo-day-pagination-next")).toBeEnabled();
|
||||||
await expect(page.getByText("WO-1000").first()).toBeVisible();
|
await expect(page.getByText("WO-1000").first()).toBeVisible();
|
||||||
await expect(page.getByText("WO-1050")).toHaveCount(0);
|
await expect(page.getByText("WO-1050")).toHaveCount(0);
|
||||||
|
|
||||||
await page.getByLabel("Rows per page").selectOption("25");
|
await pager.getByLabel("Rows per page").selectOption("25");
|
||||||
await expect(page.getByText("Page 1 of 3")).toBeVisible();
|
await expect(pager.getByText("Page 1 of 3")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("wo-day-pagination-next")).toBeEnabled();
|
||||||
await expect(page.getByText("WO-1025")).toHaveCount(0);
|
await expect(page.getByText("WO-1025")).toHaveCount(0);
|
||||||
|
|
||||||
await page.getByTestId("wo-day-pagination-next").evaluate((node) => {
|
await hideQueryDevtools(page);
|
||||||
(node as HTMLButtonElement).click();
|
await page.getByTestId("wo-day-pagination-next").click();
|
||||||
});
|
await expect(pager.getByText("Page 2 of 3")).toBeVisible();
|
||||||
await expect(page.getByText("Page 2 of 3")).toBeVisible();
|
|
||||||
await expect(page.getByText("WO-1025").first()).toBeVisible();
|
await expect(page.getByText("WO-1025").first()).toBeVisible();
|
||||||
await expect(page.getByText("WO-1000")).toHaveCount(0);
|
await expect(page.getByText("WO-1000")).toHaveCount(0);
|
||||||
|
|
||||||
await page.getByRole("tab", { name: "Mon · 08/17" }).click({ force: true });
|
await page.getByRole("tab", { name: "Mon · 08/17" }).click({ force: true });
|
||||||
await expect(page.getByText("Page 1 of 1")).toBeVisible();
|
await expect(pager.getByText("Page 1 of 1")).toBeVisible();
|
||||||
await expect(page.getByText("MON-2000").first()).toBeVisible();
|
await expect(page.getByText("MON-2000").first()).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Week 8/17–8/21" }).click({ force: true });
|
await page.getByRole("button", { name: "Week 8/17–8/21" }).click({ force: true });
|
||||||
await page.getByRole("button", { name: /Aug 10/ }).click({ force: true });
|
await page.getByRole("button", { name: /Aug 10/ }).click({ force: true });
|
||||||
await expect(page.getByTestId("wo-day-pagination")).toBeVisible();
|
await expect(page.getByTestId("wo-day-pagination")).toBeVisible();
|
||||||
await expect(page.getByText("Page 1 of")).toBeVisible();
|
await expect(page.getByTestId("wo-day-pagination").getByText("Page 1 of")).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Advanced Filters" }).click();
|
await page.getByRole("button", { name: "Advanced Filters" }).click();
|
||||||
await page.getByRole("button", { name: /Indicators/i }).click();
|
await page.getByRole("button", { name: /Indicators/i }).click();
|
||||||
await page.getByRole("button", { name: "Rescheduled ≥ 2" }).click();
|
await page.getByRole("button", { name: "Rescheduled ≥ 2" }).click();
|
||||||
await page.getByRole("button", { name: "Apply filters" }).click();
|
await page.getByRole("button", { name: "Apply filters" }).click();
|
||||||
await expect(pager).toHaveCount(0);
|
await expect(pager).toBeVisible();
|
||||||
|
await expect(pager.getByText("RESULTS · 1778 items")).toBeVisible();
|
||||||
|
await expect(pager.getByText("Page 1 of 36")).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Clear all" }).click();
|
await page.getByRole("button", { name: "Clear all" }).click();
|
||||||
await expect(page.getByTestId("wo-day-pagination")).toBeVisible();
|
await expect(page.getByTestId("wo-day-pagination")).toBeVisible();
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ export function readTotalCount(record: Record<string, unknown>, fallback: number
|
||||||
return (
|
return (
|
||||||
(typeof record.totalCount === "number" ? record.totalCount : undefined) ??
|
(typeof record.totalCount === "number" ? record.totalCount : undefined) ??
|
||||||
(typeof record.TotalCount === "number" ? record.TotalCount : undefined) ??
|
(typeof record.TotalCount === "number" ? record.TotalCount : undefined) ??
|
||||||
|
(typeof record.total === "number" ? record.total : undefined) ??
|
||||||
|
(typeof record.Total === "number" ? record.Total : undefined) ??
|
||||||
(typeof record.Count === "number" ? record.Count : undefined) ??
|
(typeof record.Count === "number" ? record.Count : undefined) ??
|
||||||
fallback
|
fallback
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { CircularProgress } from "@mui/material";
|
import { CircularProgress } from "@mui/material";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
|
import { AvetaBadge } from "@/app/(protected)/workorders/_components/list/table/cells/aveta-badge";
|
||||||
|
import { RescheduleCounterPill } from "@/app/(protected)/workorders/_components/list/table/cells/wo-counter-pills";
|
||||||
import { WoTableStatusLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label";
|
import { WoTableStatusLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label";
|
||||||
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
|
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
@ -34,6 +36,10 @@ export function SlideOverHeader({
|
||||||
>
|
>
|
||||||
#{row.woNumber || "—"}
|
#{row.woNumber || "—"}
|
||||||
</Text>
|
</Text>
|
||||||
|
<RescheduleCounterPill count={row.rescheduleCount} originalDate={row.originalDate} />
|
||||||
|
{Boolean(row.avetaRequired) && (
|
||||||
|
<AvetaBadge size={13} attached={Boolean(row.hasAvetaDocument)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{isCancelled && (
|
{isCancelled && (
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,18 @@ import {
|
||||||
Film,
|
Film,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
|
ShieldCheck,
|
||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
import {
|
||||||
|
hasExplicitAvetaFile,
|
||||||
|
resolveExtraDocCategory,
|
||||||
|
} from "@/domain/work-orders/utils/extra-docs-aveta";
|
||||||
import {
|
import {
|
||||||
canDeleteWorkOrderMedia,
|
canDeleteWorkOrderMedia,
|
||||||
formatMediaSizeLimit,
|
formatMediaSizeLimit,
|
||||||
|
|
@ -27,22 +33,113 @@ const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||||
other: Paperclip,
|
other: Paperclip,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EXTRA_DOC_PURPOSES: Array<Extract<UploadableMediaCategory, "Extra" | "Aveta">> = [
|
||||||
|
"Extra",
|
||||||
|
"Aveta",
|
||||||
|
];
|
||||||
|
|
||||||
type SlideOverExtrasTabProps = {
|
type SlideOverExtrasTabProps = {
|
||||||
uploads: MediaFile[];
|
uploads: MediaFile[];
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
pending?: boolean;
|
pending?: boolean;
|
||||||
|
avetaRequired?: boolean;
|
||||||
onUpload: (files: File[]) => void;
|
onUpload: (files: File[]) => void;
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
|
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function ExtraDocRow({
|
||||||
|
file,
|
||||||
|
isReadOnly,
|
||||||
|
pending,
|
||||||
|
onRemove,
|
||||||
|
onCategorize,
|
||||||
|
}: {
|
||||||
|
file: MediaFile;
|
||||||
|
isReadOnly: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||||
|
}) {
|
||||||
|
const category = resolveExtraDocCategory(file);
|
||||||
|
const isAveta = category === "Aveta";
|
||||||
|
const ItemIcon = isAveta ? ShieldCheck : FILETYPE_ICON[file.fileType];
|
||||||
|
const showDelete = !isReadOnly && canDeleteWorkOrderMedia(file);
|
||||||
|
const showCategory = !isReadOnly && Boolean(onCategorize) && !file.isLegacy;
|
||||||
|
const purpose = file.purpose === "Aveta" || isAveta ? "Aveta" : "Extra";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
<ItemIcon
|
||||||
|
size={12}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ color: isAveta ? "var(--warning)" : "var(--color-text-muted)" }}
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href={file.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="min-w-0 flex-1 truncate hover:underline"
|
||||||
|
style={{ fontSize: 12, color: "var(--foreground)" }}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</a>
|
||||||
|
{showCategory ? (
|
||||||
|
<select
|
||||||
|
aria-label={`Category for ${file.name}`}
|
||||||
|
value={purpose}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
if (value === "Extra" || value === "Aveta") {
|
||||||
|
onCategorize?.(file.id, value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded border px-1 py-0.5 text-[10px]"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--color-border)",
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{EXTRA_DOC_PURPOSES.map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{value}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
isAveta && (
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 600, color: "var(--warning)" }}>Aveta</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{showDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Remove"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => onRemove(file.id)}
|
||||||
|
className="rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-50"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SlideOverExtrasTab({
|
export function SlideOverExtrasTab({
|
||||||
uploads,
|
uploads,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
pending = false,
|
pending = false,
|
||||||
|
avetaRequired = false,
|
||||||
onUpload,
|
onUpload,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onCategorize,
|
||||||
}: SlideOverExtrasTabProps) {
|
}: SlideOverExtrasTabProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const avetaAttached = hasExplicitAvetaFile(uploads);
|
||||||
|
|
||||||
const addFiles = (files: FileList | null) => {
|
const addFiles = (files: FileList | null) => {
|
||||||
if (isReadOnly || pending || !files || files.length === 0) return;
|
if (isReadOnly || pending || !files || files.length === 0) return;
|
||||||
|
|
@ -70,6 +167,19 @@ export function SlideOverExtrasTab({
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
|
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
|
||||||
Extra Documents
|
Extra Documents
|
||||||
</span>
|
</span>
|
||||||
|
{Boolean(avetaRequired) && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: avetaAttached ? "var(--success)" : "var(--warning)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShieldCheck size={12} />
|
||||||
|
Aveta
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isReadOnly && (
|
{!isReadOnly && (
|
||||||
|
|
@ -122,40 +232,16 @@ export function SlideOverExtrasTab({
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{uploads.map((file) => {
|
{uploads.map((file) => (
|
||||||
const ItemIcon = FILETYPE_ICON[file.fileType];
|
<ExtraDocRow
|
||||||
const showDelete = !isReadOnly && canDeleteWorkOrderMedia(file);
|
key={file.id}
|
||||||
return (
|
file={file}
|
||||||
<div key={file.id} className="flex items-center gap-2 py-1">
|
isReadOnly={isReadOnly}
|
||||||
<ItemIcon
|
pending={pending}
|
||||||
size={12}
|
onRemove={onRemove}
|
||||||
className="shrink-0"
|
onCategorize={onCategorize}
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
/>
|
||||||
/>
|
))}
|
||||||
<a
|
|
||||||
href={file.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="min-w-0 flex-1 truncate hover:underline"
|
|
||||||
style={{ fontSize: 12, color: "var(--foreground)" }}
|
|
||||||
>
|
|
||||||
{file.name}
|
|
||||||
</a>
|
|
||||||
{showDelete && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title="Remove"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={() => onRemove(file.id)}
|
|
||||||
className="rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-50"
|
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { SiteCell } from "@/app/(protected)/workorders/_components/list/table/ce
|
||||||
import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
|
import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
|
||||||
import { VendorCell } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-cell";
|
import { VendorCell } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-cell";
|
||||||
import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
|
import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
|
||||||
|
import { AvetaBadge } from "@/app/(protected)/workorders/_components/list/table/cells/aveta-badge";
|
||||||
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||||
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
|
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -100,6 +101,8 @@ export function SlideOverInfoTabDisplayView({
|
||||||
pmTypes={pmTypes}
|
pmTypes={pmTypes}
|
||||||
serviceNotes={source.serviceNotes}
|
serviceNotes={source.serviceNotes}
|
||||||
extraServices={source.extraServices}
|
extraServices={source.extraServices}
|
||||||
|
avetaRequired={source.avetaRequired}
|
||||||
|
avetaAttached={source.hasAvetaDocument}
|
||||||
woNumber={source.woNumber}
|
woNumber={source.woNumber}
|
||||||
viewOnly
|
viewOnly
|
||||||
onSave={() => undefined}
|
onSave={() => undefined}
|
||||||
|
|
@ -108,6 +111,9 @@ export function SlideOverInfoTabDisplayView({
|
||||||
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
|
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
|
||||||
<PmTypeIcon pmType={source.pm} size={14} />
|
<PmTypeIcon pmType={source.pm} size={14} />
|
||||||
{source.pm}
|
{source.pm}
|
||||||
|
{Boolean(source.avetaRequired) && (
|
||||||
|
<AvetaBadge size={11} attached={Boolean(source.hasAvetaDocument)} />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: "var(--color-text-muted)" }}>—</span>
|
<span style={{ color: "var(--color-text-muted)" }}>—</span>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell";
|
import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell";
|
||||||
import { ApptTimeCell } from "@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell";
|
import { ApptTimeCell } from "@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell";
|
||||||
import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell";
|
import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell";
|
||||||
|
import { DueDateCell } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-cell";
|
||||||
import { EditableWONumberCell } from "@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell";
|
import { EditableWONumberCell } from "@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell";
|
||||||
import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
|
import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
|
||||||
import { SiteCell } from "@/app/(protected)/workorders/_components/list/table/cells/site-cell";
|
import { SiteCell } from "@/app/(protected)/workorders/_components/list/table/cells/site-cell";
|
||||||
|
|
@ -48,6 +49,7 @@ export function SlideOverInfoTabEditView({
|
||||||
value={draft.woNumber}
|
value={draft.woNumber}
|
||||||
rescheduleCount={draft.rescheduleCount}
|
rescheduleCount={draft.rescheduleCount}
|
||||||
carriedOver={draft.carriedOver}
|
carriedOver={draft.carriedOver}
|
||||||
|
originalDate={draft.originalDate}
|
||||||
currentId={draft.id}
|
currentId={draft.id}
|
||||||
allOrders={allOrders}
|
allOrders={allOrders}
|
||||||
onSave={(woNumber) => onDraftChange({ woNumber })}
|
onSave={(woNumber) => onDraftChange({ woNumber })}
|
||||||
|
|
@ -113,7 +115,11 @@ export function SlideOverInfoTabEditView({
|
||||||
/>
|
/>
|
||||||
</SlideOverInfoRow>
|
</SlideOverInfoRow>
|
||||||
<SlideOverInfoRow label="Due Date">
|
<SlideOverInfoRow label="Due Date">
|
||||||
<DateCell value={draft.dueDate} onSave={(dueDate) => onDraftChange({ dueDate })} />
|
<DueDateCell
|
||||||
|
type={draft.type}
|
||||||
|
value={draft.dueDate}
|
||||||
|
onSave={(dueDate) => onDraftChange({ dueDate })}
|
||||||
|
/>
|
||||||
</SlideOverInfoRow>
|
</SlideOverInfoRow>
|
||||||
<SlideOverInfoRow label="Appt Time">
|
<SlideOverInfoRow label="Appt Time">
|
||||||
<ApptTimeCell
|
<ApptTimeCell
|
||||||
|
|
@ -127,12 +133,15 @@ export function SlideOverInfoTabEditView({
|
||||||
pmTypes={pmTypes}
|
pmTypes={pmTypes}
|
||||||
serviceNotes={draft.serviceNotes}
|
serviceNotes={draft.serviceNotes}
|
||||||
extraServices={draft.extraServices}
|
extraServices={draft.extraServices}
|
||||||
|
avetaRequired={draft.avetaRequired}
|
||||||
woNumber={draft.woNumber}
|
woNumber={draft.woNumber}
|
||||||
onSave={(pm) => onDraftChange({ pm })}
|
onSave={(pm) => onDraftChange({ pm })}
|
||||||
onSaveDetails={(details) =>
|
onSaveDetails={(details) =>
|
||||||
onDraftChange({
|
onDraftChange({
|
||||||
|
pm: details.pm,
|
||||||
serviceNotes: details.serviceNotes,
|
serviceNotes: details.serviceNotes,
|
||||||
extraServices: details.extraServices,
|
extraServices: details.extraServices,
|
||||||
|
avetaRequired: details.avetaRequired,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,9 @@ export function useSlideOverMediaState({
|
||||||
const extrasUploads = useMemo(
|
const extrasUploads = useMemo(
|
||||||
() =>
|
() =>
|
||||||
boardMediaItemsToMediaFiles(
|
boardMediaItemsToMediaFiles(
|
||||||
(detailForRow?.media ?? []).filter((item) => item.category === "Extra"),
|
(detailForRow?.media ?? []).filter(
|
||||||
|
(item) => item.category === "Extra" || item.category === "Aveta",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
[detailForRow?.media],
|
[detailForRow?.media],
|
||||||
);
|
);
|
||||||
|
|
@ -65,6 +67,7 @@ export function useSlideOverMediaState({
|
||||||
setMediaBatchPending(true);
|
setMediaBatchPending(true);
|
||||||
try {
|
try {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
|
// Upload stays Extra; Aveta is applied only by an explicit Extra Docs PATCH.
|
||||||
await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
WorkOrderUser,
|
WorkOrderUser,
|
||||||
} from "@/domain/work-orders/types/work-order";
|
} from "@/domain/work-orders/types/work-order";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { resolveSlideOverAvetaRequired } from "@/domain/work-orders/utils/resolve-slide-over-aveta-required";
|
||||||
import type { useWorkOrderSlideOver, SlideOverTab } from "./use-work-order-slide-over";
|
import type { useWorkOrderSlideOver, SlideOverTab } from "./use-work-order-slide-over";
|
||||||
import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel";
|
import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel";
|
||||||
|
|
||||||
|
|
@ -146,12 +147,18 @@ export function WorkOrderSlideOverTabs({
|
||||||
>
|
>
|
||||||
<SlideOverExtrasTab
|
<SlideOverExtrasTab
|
||||||
uploads={state.extrasUploads}
|
uploads={state.extrasUploads}
|
||||||
|
avetaRequired={resolveSlideOverAvetaRequired(
|
||||||
|
state.editing,
|
||||||
|
infoSource?.avetaRequired,
|
||||||
|
activeDraft?.avetaRequired,
|
||||||
|
)}
|
||||||
isReadOnly={state.isExtrasReadOnly}
|
isReadOnly={state.isExtrasReadOnly}
|
||||||
pending={state.mediaPending}
|
pending={state.mediaPending}
|
||||||
onUpload={(files) => {
|
onUpload={(files) => {
|
||||||
void state.uploadExtraFiles(files);
|
void state.uploadExtraFiles(files);
|
||||||
}}
|
}}
|
||||||
onRemove={state.removeMedia}
|
onRemove={state.removeMedia}
|
||||||
|
onCategorize={state.categorizeMedia}
|
||||||
/>
|
/>
|
||||||
</WorkOrderSlideOverTabPanel>
|
</WorkOrderSlideOverTabPanel>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,11 @@ import {
|
||||||
import { AdvDocPill, AdvStatusPill } from "./adv-filter-pill";
|
import { AdvDocPill, AdvStatusPill } from "./adv-filter-pill";
|
||||||
import { AdvDateRangeSection } from "./adv-date-range-section";
|
import { AdvDateRangeSection } from "./adv-date-range-section";
|
||||||
import { AdvMultiSelect } from "./adv-multi-select";
|
import { AdvMultiSelect } from "./adv-multi-select";
|
||||||
import { AdvFlagInternalUpliftSections, AdvIndicatorsSection } from "./adv-parity-filter-sections";
|
import {
|
||||||
|
AdvAvetaSection,
|
||||||
|
AdvFlagInternalUpliftSections,
|
||||||
|
AdvIndicatorsSection,
|
||||||
|
} from "./adv-parity-filter-sections";
|
||||||
import { AdvSection } from "./adv-section";
|
import { AdvSection } from "./adv-section";
|
||||||
import { AdvSeverityList } from "./adv-severity-list";
|
import { AdvSeverityList } from "./adv-severity-list";
|
||||||
import { AdvTypeGrid } from "./adv-type-grid";
|
import { AdvTypeGrid } from "./adv-type-grid";
|
||||||
|
|
@ -127,6 +131,8 @@ export function AdvancedSearchFilterSections({
|
||||||
|
|
||||||
<AdvIndicatorsSection draft={draft} setDraft={setDraft} />
|
<AdvIndicatorsSection draft={draft} setDraft={setDraft} />
|
||||||
|
|
||||||
|
<AdvAvetaSection draft={draft} setDraft={setDraft} />
|
||||||
|
|
||||||
<AdvSection icon={Flame} label="Service" count={draft.pmTypes.size}>
|
<AdvSection icon={Flame} label="Service" count={draft.pmTypes.size}>
|
||||||
<AdvMultiSelect
|
<AdvMultiSelect
|
||||||
options={pmTypes.map((pmType) => ({ value: pmType, label: pmType }))}
|
options={pmTypes.map((pmType) => ({ value: pmType, label: pmType }))}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||||
import { ArrowRight, Check, DollarSign, Flag, Hash, Plus, RefreshCw } from "lucide-react";
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Check,
|
||||||
|
DollarSign,
|
||||||
|
Flag,
|
||||||
|
Hash,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
ShieldCheck,
|
||||||
|
} from "lucide-react";
|
||||||
import { Checkbox } from "@mui/material";
|
import { Checkbox } from "@mui/material";
|
||||||
import { AdvDocPill } from "./adv-filter-pill";
|
import { AdvDocPill } from "./adv-filter-pill";
|
||||||
import { AdvMultiSelect } from "./adv-multi-select";
|
import { AdvMultiSelect } from "./adv-multi-select";
|
||||||
|
|
@ -115,6 +124,26 @@ export function AdvIndicatorsSection({ draft, setDraft }: DraftProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AdvAvetaSection({ draft, setDraft }: DraftProps) {
|
||||||
|
return (
|
||||||
|
<AdvSection icon={ShieldCheck} label="Aveta Compliance" count={draft.avetaOnly ? 1 : 0}>
|
||||||
|
<label className="flex cursor-pointer items-center gap-1.5 select-none">
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={draft.avetaOnly}
|
||||||
|
onChange={() => setDraft((prev) => ({ ...prev, avetaOnly: !prev.avetaOnly }))}
|
||||||
|
sx={{
|
||||||
|
padding: 0,
|
||||||
|
color: "var(--color-border-hover)",
|
||||||
|
"&.Mui-checked": { color: "var(--warning)" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">Aveta required only</span>
|
||||||
|
</label>
|
||||||
|
</AdvSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AdvFlagInternalUpliftSections({
|
export function AdvFlagInternalUpliftSections({
|
||||||
draft,
|
draft,
|
||||||
setDraft,
|
setDraft,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ export function buildInlineDraftCreateInput(
|
||||||
dueDate: draft.dueDate,
|
dueDate: draft.dueDate,
|
||||||
pm: draft.pm,
|
pm: draft.pm,
|
||||||
extraServices: draft.extraServices ?? [],
|
extraServices: draft.extraServices ?? [],
|
||||||
|
avetaRequired: Boolean(draft.avetaRequired),
|
||||||
pmNote: draft.serviceNotes ?? "",
|
pmNote: draft.serviceNotes ?? "",
|
||||||
vendorId: draft.vendorId || "",
|
vendorId: draft.vendorId || "",
|
||||||
vendorName: draft.company,
|
vendorName: draft.company,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
import { Tooltip } from "@mui/material";
|
||||||
|
|
||||||
|
type AvetaBadgeProps = {
|
||||||
|
size?: number;
|
||||||
|
attached?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Per-WO Aveta shield — never a site-level flag. Color is paired with the icon. */
|
||||||
|
export function AvetaBadge({ size = 11, attached = false }: AvetaBadgeProps) {
|
||||||
|
const color = attached ? "var(--success)" : "var(--warning)";
|
||||||
|
const title = attached
|
||||||
|
? "Aveta documentation is attached"
|
||||||
|
: "This work order requires Aveta compliance documentation";
|
||||||
|
const label = attached ? "Aveta attached" : "Aveta required";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={title} arrow placement="top">
|
||||||
|
<span className="inline-flex shrink-0" aria-label={label}>
|
||||||
|
<ShieldCheck size={size} style={{ color }} />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
type AvetaCheckboxProps = {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AvetaCheckbox({ checked, onChange, disabled = false }: AvetaCheckboxProps) {
|
||||||
|
return (
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => onChange(event.target.checked)}
|
||||||
|
className="size-3.5 accent-[var(--warning)]"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Requires Aveta documentation
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,7 @@ export function DueDateCell({ type, value, q, highlight, onSave }: DueDateCellPr
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
aria-label="Due Date"
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
|
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,14 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
|
||||||
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
|
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
|
||||||
import { EditableWONumberInput } from "./editable-wo-number-input";
|
import { EditableWONumberInput } from "./editable-wo-number-input";
|
||||||
import { HighlightText } from "./highlight-text";
|
import { HighlightText } from "./highlight-text";
|
||||||
|
import { AddOnPill, CarriedOverCounterPill, RescheduleCounterPill } from "./wo-counter-pills";
|
||||||
|
|
||||||
type EditableWONumberCellProps = {
|
type EditableWONumberCellProps = {
|
||||||
value: string;
|
value: string;
|
||||||
rescheduleCount: number;
|
rescheduleCount: number;
|
||||||
carriedOver?: number;
|
carriedOver?: number;
|
||||||
isAddOn?: boolean;
|
isAddOn?: boolean;
|
||||||
|
originalDate?: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
currentId: string | number;
|
currentId: string | number;
|
||||||
allOrders: WorkOrderTableRow[];
|
allOrders: WorkOrderTableRow[];
|
||||||
|
|
@ -19,42 +21,6 @@ type EditableWONumberCellProps = {
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function CounterPill({ label, count }: { label: string; count: number }) {
|
|
||||||
if (count < 2) return null;
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
title={label}
|
|
||||||
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
|
|
||||||
style={{
|
|
||||||
background: "var(--color-bg-muted)",
|
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddOnPill({ when }: { when: boolean }) {
|
|
||||||
if (!when) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
title="Add-On"
|
|
||||||
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
|
|
||||||
style={{
|
|
||||||
background: "var(--type-addon-bg)",
|
|
||||||
color: "var(--type-addon-text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add-On
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveDuplicateWoNumber(
|
async function resolveDuplicateWoNumber(
|
||||||
draft: string,
|
draft: string,
|
||||||
allOrders: WorkOrderTableRow[],
|
allOrders: WorkOrderTableRow[],
|
||||||
|
|
@ -70,6 +36,7 @@ export function EditableWONumberCell({
|
||||||
rescheduleCount,
|
rescheduleCount,
|
||||||
carriedOver = 0,
|
carriedOver = 0,
|
||||||
isAddOn = false,
|
isAddOn = false,
|
||||||
|
originalDate,
|
||||||
q,
|
q,
|
||||||
currentId,
|
currentId,
|
||||||
allOrders,
|
allOrders,
|
||||||
|
|
@ -150,8 +117,8 @@ export function EditableWONumberCell({
|
||||||
{value ? <HighlightText text={value} q={q} /> : "-"}
|
{value ? <HighlightText text={value} q={q} /> : "-"}
|
||||||
</span>
|
</span>
|
||||||
<AddOnPill when={isAddOn} />
|
<AddOnPill when={isAddOn} />
|
||||||
<CounterPill label="Reschedules" count={rescheduleCount} />
|
<RescheduleCounterPill count={rescheduleCount} originalDate={originalDate} />
|
||||||
<CounterPill label="Carried over" count={carriedOver} />
|
<CarriedOverCounterPill count={carriedOver} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -187,8 +154,8 @@ export function EditableWONumberCell({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<AddOnPill when={isAddOn} />
|
<AddOnPill when={isAddOn} />
|
||||||
<CounterPill label="Reschedules" count={rescheduleCount} />
|
<RescheduleCounterPill count={rescheduleCount} originalDate={originalDate} />
|
||||||
<CounterPill label="Carried over" count={carriedOver} />
|
<CarriedOverCounterPill count={carriedOver} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text";
|
import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text";
|
||||||
import { HoverChevron } from "@/app/(protected)/workorders/_components/list/table/cells/hover-chevron";
|
import { HoverChevron } from "@/app/(protected)/workorders/_components/list/table/cells/hover-chevron";
|
||||||
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||||
|
import { AvetaBadge } from "@/app/(protected)/workorders/_components/list/table/cells/aveta-badge";
|
||||||
import { PmTypeDetailsTrigger } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger";
|
import { PmTypeDetailsTrigger } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger";
|
||||||
import { PmTypePickerPopover } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover";
|
import { PmTypePickerPopover } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover";
|
||||||
import {
|
import {
|
||||||
|
|
@ -20,6 +21,8 @@ type PMTypeCellProps = {
|
||||||
onSave: (v: string) => void;
|
onSave: (v: string) => void;
|
||||||
serviceNotes?: string;
|
serviceNotes?: string;
|
||||||
extraServices?: string[];
|
extraServices?: string[];
|
||||||
|
avetaRequired?: boolean;
|
||||||
|
avetaAttached?: boolean;
|
||||||
woNumber?: string;
|
woNumber?: string;
|
||||||
onSaveDetails?: (details: ServiceNotesDetails) => void;
|
onSaveDetails?: (details: ServiceNotesDetails) => void;
|
||||||
viewOnly?: boolean;
|
viewOnly?: boolean;
|
||||||
|
|
@ -32,6 +35,8 @@ export function PMTypeCell({
|
||||||
onSave,
|
onSave,
|
||||||
serviceNotes = "",
|
serviceNotes = "",
|
||||||
extraServices = [],
|
extraServices = [],
|
||||||
|
avetaRequired = false,
|
||||||
|
avetaAttached = false,
|
||||||
woNumber,
|
woNumber,
|
||||||
onSaveDetails,
|
onSaveDetails,
|
||||||
viewOnly = false,
|
viewOnly = false,
|
||||||
|
|
@ -45,7 +50,7 @@ export function PMTypeCell({
|
||||||
: pmTypes;
|
: pmTypes;
|
||||||
|
|
||||||
const extras = extraServices ?? [];
|
const extras = extraServices ?? [];
|
||||||
const hasDetails = hasServiceNotesDetails(serviceNotes, extras);
|
const hasDetails = hasServiceNotesDetails(serviceNotes, extras, avetaRequired);
|
||||||
const notePreview = serviceNotes?.trim() ? truncateServiceNotesPreview(serviceNotes) : "";
|
const notePreview = serviceNotes?.trim() ? truncateServiceNotesPreview(serviceNotes) : "";
|
||||||
|
|
||||||
const closePopover = () => {
|
const closePopover = () => {
|
||||||
|
|
@ -61,6 +66,7 @@ export function PMTypeCell({
|
||||||
pm={value}
|
pm={value}
|
||||||
extraServices={extras}
|
extraServices={extras}
|
||||||
serviceNotes={serviceNotes ?? ""}
|
serviceNotes={serviceNotes ?? ""}
|
||||||
|
avetaRequired={avetaRequired}
|
||||||
woNumber={woNumber}
|
woNumber={woNumber}
|
||||||
pmTypes={pmTypes}
|
pmTypes={pmTypes}
|
||||||
viewOnly={viewOnly}
|
viewOnly={viewOnly}
|
||||||
|
|
@ -95,6 +101,7 @@ export function PMTypeCell({
|
||||||
) : (
|
) : (
|
||||||
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>—</span>
|
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>—</span>
|
||||||
)}
|
)}
|
||||||
|
{Boolean(avetaRequired) && <AvetaBadge size={11} attached={avetaAttached} />}
|
||||||
</button>
|
</button>
|
||||||
{modal}
|
{modal}
|
||||||
</>
|
</>
|
||||||
|
|
@ -109,6 +116,8 @@ export function PMTypeCell({
|
||||||
q={q}
|
q={q}
|
||||||
notePreview={notePreview}
|
notePreview={notePreview}
|
||||||
extrasCount={extras.length}
|
extrasCount={extras.length}
|
||||||
|
avetaRequired={avetaRequired}
|
||||||
|
avetaAttached={avetaAttached}
|
||||||
onOpenDetails={() => setModalOpen(true)}
|
onOpenDetails={() => setModalOpen(true)}
|
||||||
/>
|
/>
|
||||||
{modal}
|
{modal}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { AvetaBadge } from "@/app/(protected)/workorders/_components/list/table/cells/aveta-badge";
|
||||||
import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text";
|
import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text";
|
||||||
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||||
|
|
||||||
|
|
@ -6,6 +7,8 @@ type PmTypeDetailsTriggerProps = {
|
||||||
q?: string;
|
q?: string;
|
||||||
notePreview: string;
|
notePreview: string;
|
||||||
extrasCount: number;
|
extrasCount: number;
|
||||||
|
avetaRequired?: boolean;
|
||||||
|
avetaAttached?: boolean;
|
||||||
onOpenDetails: () => void;
|
onOpenDetails: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -14,6 +17,8 @@ export function PmTypeDetailsTrigger({
|
||||||
q,
|
q,
|
||||||
notePreview,
|
notePreview,
|
||||||
extrasCount,
|
extrasCount,
|
||||||
|
avetaRequired = false,
|
||||||
|
avetaAttached = false,
|
||||||
onOpenDetails,
|
onOpenDetails,
|
||||||
}: PmTypeDetailsTriggerProps) {
|
}: PmTypeDetailsTriggerProps) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -41,6 +46,7 @@ export function PmTypeDetailsTrigger({
|
||||||
>
|
>
|
||||||
{value ? <HighlightText text={value} q={q} /> : "Select …"}
|
{value ? <HighlightText text={value} q={q} /> : "Select …"}
|
||||||
</span>
|
</span>
|
||||||
|
{Boolean(avetaRequired) && <AvetaBadge size={11} attached={avetaAttached} />}
|
||||||
</span>
|
</span>
|
||||||
{notePreview ? (
|
{notePreview ? (
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export type ServiceNotesDetails = {
|
||||||
pm: string;
|
pm: string;
|
||||||
extraServices: string[];
|
extraServices: string[];
|
||||||
serviceNotes: string;
|
serviceNotes: string;
|
||||||
|
avetaRequired: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ServiceNotesDialogProps = {
|
type ServiceNotesDialogProps = {
|
||||||
|
|
@ -17,6 +18,7 @@ type ServiceNotesDialogProps = {
|
||||||
pm: string;
|
pm: string;
|
||||||
extraServices: string[];
|
extraServices: string[];
|
||||||
serviceNotes: string;
|
serviceNotes: string;
|
||||||
|
avetaRequired?: boolean;
|
||||||
woNumber?: string;
|
woNumber?: string;
|
||||||
pmTypes: string[];
|
pmTypes: string[];
|
||||||
viewOnly?: boolean;
|
viewOnly?: boolean;
|
||||||
|
|
@ -29,6 +31,7 @@ export function ServiceNotesDialog({
|
||||||
pm,
|
pm,
|
||||||
extraServices,
|
extraServices,
|
||||||
serviceNotes,
|
serviceNotes,
|
||||||
|
avetaRequired = false,
|
||||||
woNumber,
|
woNumber,
|
||||||
pmTypes,
|
pmTypes,
|
||||||
viewOnly = false,
|
viewOnly = false,
|
||||||
|
|
@ -36,6 +39,7 @@ export function ServiceNotesDialog({
|
||||||
}: ServiceNotesDialogProps) {
|
}: ServiceNotesDialogProps) {
|
||||||
const [sel, setSel] = useState<string[]>([]);
|
const [sel, setSel] = useState<string[]>([]);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
|
const [aveta, setAveta] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -44,8 +48,9 @@ export function ServiceNotesDialog({
|
||||||
}
|
}
|
||||||
setSel([pm, ...(extraServices ?? [])].filter((p, i, a) => !!p && a.indexOf(p) === i));
|
setSel([pm, ...(extraServices ?? [])].filter((p, i, a) => !!p && a.indexOf(p) === i));
|
||||||
setNote(serviceNotes ?? "");
|
setNote(serviceNotes ?? "");
|
||||||
|
setAveta(Boolean(avetaRequired));
|
||||||
setSearch("");
|
setSearch("");
|
||||||
}, [open, pm, extraServices, serviceNotes]);
|
}, [open, pm, extraServices, serviceNotes, avetaRequired]);
|
||||||
|
|
||||||
const toggle = (value: string) => {
|
const toggle = (value: string) => {
|
||||||
setSel((cur) => (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value]));
|
setSel((cur) => (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value]));
|
||||||
|
|
@ -87,6 +92,8 @@ export function ServiceNotesDialog({
|
||||||
setNote={setNote}
|
setNote={setNote}
|
||||||
search={search}
|
search={search}
|
||||||
setSearch={setSearch}
|
setSearch={setSearch}
|
||||||
|
avetaRequired={aveta}
|
||||||
|
onAvetaRequiredChange={setAveta}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div className="mt-2 flex items-center justify-end gap-2">
|
<div className="mt-2 flex items-center justify-end gap-2">
|
||||||
|
|
@ -116,6 +123,7 @@ export function ServiceNotesDialog({
|
||||||
pm: sel[0] ?? "",
|
pm: sel[0] ?? "",
|
||||||
extraServices: sel.slice(1),
|
extraServices: sel.slice(1),
|
||||||
serviceNotes: note,
|
serviceNotes: note,
|
||||||
|
avetaRequired: aveta,
|
||||||
});
|
});
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
import { Tooltip } from "@mui/material";
|
||||||
|
import { formatRescheduleHover } from "@/domain/work-orders/utils/reschedule-indicator";
|
||||||
|
|
||||||
|
const RESCHEDULE_PILL_STYLE = {
|
||||||
|
background: "#f5f3ff",
|
||||||
|
color: "#7c3aed",
|
||||||
|
border: "1px solid #ddd6fe",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const MUTED_PILL_STYLE = {
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const ADDON_PILL_STYLE = {
|
||||||
|
background: "var(--type-addon-bg)",
|
||||||
|
color: "var(--type-addon-text)",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function RescheduleCounterPill({
|
||||||
|
count,
|
||||||
|
originalDate,
|
||||||
|
}: {
|
||||||
|
count: number;
|
||||||
|
originalDate?: string;
|
||||||
|
}) {
|
||||||
|
if (count < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hover = formatRescheduleHover(count, originalDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={hover} arrow placement="top">
|
||||||
|
<span
|
||||||
|
aria-label={hover}
|
||||||
|
className="inline-flex h-[18px] items-center gap-0.5 rounded px-1.5 text-[10px] font-semibold"
|
||||||
|
style={RESCHEDULE_PILL_STYLE}
|
||||||
|
>
|
||||||
|
<RefreshCw size={10} className="shrink-0" />
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CarriedOverCounterPill({ count }: { count: number }) {
|
||||||
|
if (count < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title="Carried over"
|
||||||
|
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
|
||||||
|
style={MUTED_PILL_STYLE}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddOnPill({ when }: { when: boolean }) {
|
||||||
|
if (!when) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title="Add-On"
|
||||||
|
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
|
||||||
|
style={ADDON_PILL_STYLE}
|
||||||
|
>
|
||||||
|
Add-On
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { NavArrowButton } from "@/components/ui/nav-arrow-button";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import {
|
import {
|
||||||
DAY_PAGE_SIZE_OPTIONS,
|
DAY_PAGE_SIZE_OPTIONS,
|
||||||
|
asDayPageSize,
|
||||||
formatDayPaginationSummary,
|
formatDayPaginationSummary,
|
||||||
type DayPageSize,
|
type DayPageSize,
|
||||||
} from "@/domain/work-orders/utils/day-pagination";
|
} from "@/domain/work-orders/utils/day-pagination";
|
||||||
|
|
@ -16,6 +17,7 @@ type WoDayPaginationFooterProps = {
|
||||||
onPageSizeChange: (pageSize: DayPageSize) => void;
|
onPageSizeChange: (pageSize: DayPageSize) => void;
|
||||||
onPrev: () => void;
|
onPrev: () => void;
|
||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
|
testId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WoDayPaginationFooter({
|
export function WoDayPaginationFooter({
|
||||||
|
|
@ -27,6 +29,7 @@ export function WoDayPaginationFooter({
|
||||||
onPageSizeChange,
|
onPageSizeChange,
|
||||||
onPrev,
|
onPrev,
|
||||||
onNext,
|
onNext,
|
||||||
|
testId = "wo-day-pagination",
|
||||||
}: WoDayPaginationFooterProps) {
|
}: WoDayPaginationFooterProps) {
|
||||||
const canPrev = pageIndex > 0;
|
const canPrev = pageIndex > 0;
|
||||||
const canNext = pageIndex < pageCount - 1;
|
const canNext = pageIndex < pageCount - 1;
|
||||||
|
|
@ -37,7 +40,7 @@ export function WoDayPaginationFooter({
|
||||||
<nav
|
<nav
|
||||||
className="flex shrink-0 items-center justify-between gap-3"
|
className="flex shrink-0 items-center justify-between gap-3"
|
||||||
aria-label="Day pagination"
|
aria-label="Day pagination"
|
||||||
data-testid="wo-day-pagination"
|
data-testid={testId}
|
||||||
>
|
>
|
||||||
<Text variant="caption" tone="muted">
|
<Text variant="caption" tone="muted">
|
||||||
{summary}
|
{summary}
|
||||||
|
|
@ -51,7 +54,7 @@ export function WoDayPaginationFooter({
|
||||||
aria-label="Rows per page"
|
aria-label="Rows per page"
|
||||||
className="rounded-md border border-border bg-card px-2 py-1 text-xs"
|
className="rounded-md border border-border bg-card px-2 py-1 text-xs"
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(event) => onPageSizeChange(Number(event.target.value) as DayPageSize)}
|
onChange={(event) => onPageSizeChange(asDayPageSize(Number(event.target.value)))}
|
||||||
>
|
>
|
||||||
{DAY_PAGE_SIZE_OPTIONS.map((option) => (
|
{DAY_PAGE_SIZE_OPTIONS.map((option) => (
|
||||||
<option key={option} value={option}>
|
<option key={option} value={option}>
|
||||||
|
|
@ -67,7 +70,7 @@ export function WoDayPaginationFooter({
|
||||||
<Text variant="caption">{pageLabel}</Text>
|
<Text variant="caption">{pageLabel}</Text>
|
||||||
<NavArrowButton
|
<NavArrowButton
|
||||||
aria-label="Next page"
|
aria-label="Next page"
|
||||||
data-testid="wo-day-pagination-next"
|
data-testid={`${testId}-next`}
|
||||||
disabled={!canNext}
|
disabled={!canNext}
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
import { Fragment, type ReactNode } from "react";
|
import { Fragment, type ReactNode } from "react";
|
||||||
import { Text } from "@/components/ui/text";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import {
|
|
||||||
UNASSIGNED_DAY_KEY,
|
|
||||||
type WorkOrderTableRow,
|
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
|
||||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||||
import { WO_TABLE_COLS } from "./wo-table-cols";
|
|
||||||
import { WoTableSectionHeader } from "./wo-table-section-header";
|
import { WoTableSectionHeader } from "./wo-table-section-header";
|
||||||
|
|
||||||
type WoTableBodyProps = {
|
type WoTableBodyProps = {
|
||||||
|
|
@ -19,60 +14,8 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
|
||||||
return <tbody>{tableData.advResults.map((row) => renderRow(row))}</tbody>;
|
return <tbody>{tableData.advResults.map((row) => renderRow(row))}</tbody>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const unassignedRows = tableData.unassigned ?? [];
|
|
||||||
const showUnassignedLoading =
|
|
||||||
tableData.unassignedOpen &&
|
|
||||||
unassignedRows.length === 0 &&
|
|
||||||
!tableData.unassignedError &&
|
|
||||||
tableData.unassignedLoading;
|
|
||||||
const showUnassignedEmpty =
|
|
||||||
tableData.unassignedOpen &&
|
|
||||||
unassignedRows.length === 0 &&
|
|
||||||
!tableData.unassignedError &&
|
|
||||||
!tableData.unassignedLoading;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tbody>
|
<tbody>
|
||||||
<WoTableSectionHeader
|
|
||||||
variant="unassigned"
|
|
||||||
label="Unassigned"
|
|
||||||
count={unassignedRows.length}
|
|
||||||
collapsed={!tableData.unassignedOpen}
|
|
||||||
onToggleCollapse={() => tableData.setUnassignedOpen((v) => !v)}
|
|
||||||
/>
|
|
||||||
{tableData.unassignedOpen && (
|
|
||||||
<>
|
|
||||||
{renderInlineRow(UNASSIGNED_DAY_KEY)}
|
|
||||||
{Boolean(tableData.unassignedError) && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={WO_TABLE_COLS.length} style={{ padding: "8px 14px" }}>
|
|
||||||
<Text variant="error" tone="error">
|
|
||||||
{tableData.unassignedError?.message ?? "Unable to load unassigned work orders."}
|
|
||||||
</Text>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{showUnassignedLoading && (
|
|
||||||
<tr aria-busy="true">
|
|
||||||
<td colSpan={WO_TABLE_COLS.length} style={{ padding: "8px 14px" }}>
|
|
||||||
<Text variant="feedback" tone="muted">
|
|
||||||
Loading unassigned work orders…
|
|
||||||
</Text>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{showUnassignedEmpty && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={WO_TABLE_COLS.length} style={{ padding: "8px 14px" }}>
|
|
||||||
<Text variant="feedback" tone="muted">
|
|
||||||
No unassigned work orders.
|
|
||||||
</Text>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{unassignedRows.map((row) => renderRow(row))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{tableData.displayGroups.map((group) => (
|
{tableData.displayGroups.map((group) => (
|
||||||
<Fragment key={group.key}>
|
<Fragment key={group.key}>
|
||||||
<WoTableSectionHeader
|
<WoTableSectionHeader
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,7 @@ export function WoTableInlineRowCells({
|
||||||
pmTypes={pmTypes}
|
pmTypes={pmTypes}
|
||||||
serviceNotes={draft.serviceNotes}
|
serviceNotes={draft.serviceNotes}
|
||||||
extraServices={draft.extraServices}
|
extraServices={draft.extraServices}
|
||||||
|
avetaRequired={draft.avetaRequired}
|
||||||
woNumber={draft.woNumber}
|
woNumber={draft.woNumber}
|
||||||
onSave={(pm) => onPatch({ pm })}
|
onSave={(pm) => onPatch({ pm })}
|
||||||
onSaveDetails={(d) =>
|
onSaveDetails={(d) =>
|
||||||
|
|
@ -185,6 +186,7 @@ export function WoTableInlineRowCells({
|
||||||
pm: d.pm,
|
pm: d.pm,
|
||||||
extraServices: d.extraServices,
|
extraServices: d.extraServices,
|
||||||
serviceNotes: d.serviceNotes,
|
serviceNotes: d.serviceNotes,
|
||||||
|
avetaRequired: d.avetaRequired,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ export function WoTableRowCells({
|
||||||
rescheduleCount={row.rescheduleCount}
|
rescheduleCount={row.rescheduleCount}
|
||||||
carriedOver={row.carriedOver}
|
carriedOver={row.carriedOver}
|
||||||
isAddOn={row.isAddOn}
|
isAddOn={row.isAddOn}
|
||||||
|
originalDate={row.originalDate}
|
||||||
q={search}
|
q={search}
|
||||||
currentId={row.id}
|
currentId={row.id}
|
||||||
allOrders={allRows}
|
allOrders={allRows}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ export function WoTableRowServiceCells({
|
||||||
q={search}
|
q={search}
|
||||||
serviceNotes={row.serviceNotes}
|
serviceNotes={row.serviceNotes}
|
||||||
extraServices={row.extraServices}
|
extraServices={row.extraServices}
|
||||||
|
avetaRequired={row.avetaRequired}
|
||||||
|
avetaAttached={row.hasAvetaDocument}
|
||||||
woNumber={row.woNumber}
|
woNumber={row.woNumber}
|
||||||
viewOnly={viewOnly}
|
viewOnly={viewOnly}
|
||||||
onSave={(pm) => onPatchRow({ pm })}
|
onSave={(pm) => onPatchRow({ pm })}
|
||||||
|
|
@ -57,6 +59,7 @@ export function WoTableRowServiceCells({
|
||||||
pm: d.pm,
|
pm: d.pm,
|
||||||
extraServices: d.extraServices,
|
extraServices: d.extraServices,
|
||||||
serviceNotes: d.serviceNotes,
|
serviceNotes: d.serviceNotes,
|
||||||
|
avetaRequired: d.avetaRequired,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export function WoTableShell({
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden" style={CARD_STYLE}>
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden" style={CARD_STYLE}>
|
||||||
<div className="min-h-0 flex-1 overflow-auto">{children}</div>
|
<div className="min-h-0 flex-1 overflow-auto">{children}</div>
|
||||||
{Boolean(footer) && (
|
{Boolean(footer) && (
|
||||||
<div className="shrink-0 border-t border-border px-3 py-2">{footer}</div>
|
<div className="relative z-10 shrink-0 border-t border-border px-3 py-2">{footer}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import type {
|
||||||
WorkOrderTablePatch,
|
WorkOrderTablePatch,
|
||||||
WorkOrderTableRow,
|
WorkOrderTableRow,
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { isUnassignedQueueReady } from "@/domain/work-orders/utils/week-day-nav-utils";
|
|
||||||
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
|
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
|
||||||
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
|
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
|
||||||
import { WoDayPaginationFooter } from "./wo-day-pagination-footer";
|
import { WoDayPaginationFooter } from "./wo-day-pagination-footer";
|
||||||
|
|
@ -71,7 +70,7 @@ function WoTableEmptyShell({ message, actionLabel, onAction }: WoTableEmptyShell
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WoTableDayFooter({ tableData }: { tableData: ReturnTypeOfUseWorkOrdersTableData }) {
|
function WoTablePager({ tableData }: { tableData: ReturnTypeOfUseWorkOrdersTableData }) {
|
||||||
if (!tableData.showDayPagination) {
|
if (!tableData.showDayPagination) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -181,7 +180,7 @@ export function WoTable({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyAdv = tableData.advActive && tableData.advResults.length === 0;
|
const emptyAdv = tableData.advActive && tableData.scopeTotalCount === 0;
|
||||||
|
|
||||||
if (emptyAdv) {
|
if (emptyAdv) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -193,10 +192,7 @@ export function WoTable({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const showSearchEmpty =
|
const showSearchEmpty = !tableData.advActive && tableData.noSearchResults;
|
||||||
!tableData.advActive &&
|
|
||||||
tableData.noSearchResults &&
|
|
||||||
isUnassignedQueueReady(tableData.unassignedLoading, tableData.unassignedError);
|
|
||||||
|
|
||||||
if (showSearchEmpty) {
|
if (showSearchEmpty) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -210,7 +206,7 @@ export function WoTable({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<WoTableShell footer={WoTableDayFooter({ tableData })}>
|
<WoTableShell footer={WoTablePager({ tableData })}>
|
||||||
<table
|
<table
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@ export function WoTableSkeleton({
|
||||||
>
|
>
|
||||||
<WoTableHead />
|
<WoTableHead />
|
||||||
<tbody>
|
<tbody>
|
||||||
<DaySkeletonSection label="Unassigned" rows={1} />
|
|
||||||
<DaySkeletonSection label={formatDayTabLabel(dayIso)} rows={rowsPerDay} />
|
<DaySkeletonSection label={formatDayTabLabel(dayIso)} rows={rowsPerDay} />
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import { getPmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
import { getPmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||||
|
import { AvetaCheckbox } from "@/app/(protected)/workorders/_components/list/table/cells/aveta-checkbox";
|
||||||
import {
|
import {
|
||||||
DLG_INPUT_CLS,
|
DLG_INPUT_CLS,
|
||||||
DLG_INPUT_STYLE,
|
DLG_INPUT_STYLE,
|
||||||
|
|
@ -14,6 +15,8 @@ type WizardServiceNotesFieldsProps = {
|
||||||
setNote: (v: string) => void;
|
setNote: (v: string) => void;
|
||||||
search: string;
|
search: string;
|
||||||
setSearch: (v: string) => void;
|
setSearch: (v: string) => void;
|
||||||
|
avetaRequired?: boolean;
|
||||||
|
onAvetaRequiredChange?: (value: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function SvcSectionLabel({ children }: { children: ReactNode }) {
|
function SvcSectionLabel({ children }: { children: ReactNode }) {
|
||||||
|
|
@ -43,6 +46,8 @@ export function WizardServiceNotesFields({
|
||||||
setNote,
|
setNote,
|
||||||
search,
|
search,
|
||||||
setSearch,
|
setSearch,
|
||||||
|
avetaRequired = false,
|
||||||
|
onAvetaRequiredChange,
|
||||||
}: WizardServiceNotesFieldsProps) {
|
}: WizardServiceNotesFieldsProps) {
|
||||||
const filtered = search
|
const filtered = search
|
||||||
? services.filter((s) => s.value.toLowerCase().includes(search.toLowerCase()))
|
? services.filter((s) => s.value.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
|
@ -124,6 +129,11 @@ export function WizardServiceNotesFields({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{onAvetaRequiredChange != null && (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<AvetaCheckbox checked={avetaRequired} onChange={onAvetaRequiredChange} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<SvcSectionLabel>Notes / Issue Detail</SvcSectionLabel>
|
<SvcSectionLabel>Notes / Issue Detail</SvcSectionLabel>
|
||||||
<textarea
|
<textarea
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,8 @@ export function WizardStepLocationServiceSelect({
|
||||||
setNote={(v) => onUpdate("pmNote", v)}
|
setNote={(v) => onUpdate("pmNote", v)}
|
||||||
search={svcSearch}
|
search={svcSearch}
|
||||||
setSearch={onSvcSearchChange}
|
setSearch={onSvcSearchChange}
|
||||||
|
avetaRequired={draft.avetaRequired}
|
||||||
|
onAvetaRequiredChange={(value) => onUpdate("avetaRequired", value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { nextSearchPageIfOutOfRange } from "@/domain/work-orders/utils/day-pagination";
|
||||||
|
|
||||||
|
type UseSyncOutOfRangeSearchPageInput = {
|
||||||
|
enabled: boolean;
|
||||||
|
ready: boolean;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalCount: number;
|
||||||
|
setPage: (page: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Keeps the requested search page inside the last valid page after the result set shrinks. */
|
||||||
|
export function useSyncOutOfRangeSearchPage(input: UseSyncOutOfRangeSearchPageInput) {
|
||||||
|
const { enabled, ready, page, pageSize, totalCount, setPage } = input;
|
||||||
|
const nextPage = nextSearchPageIfOutOfRange({ page, pageSize, totalCount });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !ready || nextPage == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPage(nextPage);
|
||||||
|
}, [enabled, ready, nextPage, setPage]);
|
||||||
|
}
|
||||||
|
|
@ -1,29 +1,22 @@
|
||||||
import { useCallback, useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||||
import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params";
|
import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params";
|
||||||
import {
|
import {
|
||||||
useWorkOrdersBoard,
|
useWorkOrdersBoard,
|
||||||
useWorkOrdersBoardSearch,
|
useWorkOrdersBoardSearch,
|
||||||
useWorkOrdersUnassignedQueue,
|
|
||||||
} from "@/domain/work-orders/use-cases/use-work-orders-list";
|
} from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||||
import {
|
import {
|
||||||
useWorkOrderLocations,
|
useWorkOrderLocations,
|
||||||
useWorkOrderVendors,
|
useWorkOrderVendors,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
import type { WorkOrderBoardListResult } from "@/domain/work-orders/types/work-order-board";
|
import type { WorkOrderBoardListResult } from "@/domain/work-orders/types/work-order-board";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
|
||||||
import type { UseQueryResult } from "@tanstack/react-query";
|
import type { UseQueryResult } from "@tanstack/react-query";
|
||||||
|
|
||||||
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
||||||
|
|
||||||
const EMPTY_UNASSIGNED_QUEUE: WorkOrderTableRow[] = [];
|
|
||||||
|
|
||||||
export type WorkOrdersBoardSource = {
|
export type WorkOrdersBoardSource = {
|
||||||
advActive: boolean;
|
advActive: boolean;
|
||||||
data: WorkOrderBoardListResult | undefined;
|
data: WorkOrderBoardListResult | undefined;
|
||||||
unassignedQueue: WorkOrderTableRow[];
|
|
||||||
unassignedError: Error | null;
|
|
||||||
unassignedLoading: boolean;
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
|
|
@ -74,35 +67,13 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar
|
||||||
error: searchError,
|
error: searchError,
|
||||||
refetch: refetchSearch,
|
refetch: refetchSearch,
|
||||||
} = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive);
|
} = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive);
|
||||||
const {
|
|
||||||
data: queueData,
|
|
||||||
isLoading: queueLoading,
|
|
||||||
isFetching: queueFetching,
|
|
||||||
error: queueError,
|
|
||||||
refetch: refetchQueue,
|
|
||||||
} = useWorkOrdersUnassignedQueue(!advActive);
|
|
||||||
|
|
||||||
const refetchBoardWithQueue: UseQueryResult<WorkOrderBoardListResult, Error>["refetch"] =
|
|
||||||
useCallback(
|
|
||||||
async (options) => {
|
|
||||||
const [boardResult] = await Promise.all([refetchBoard(options), refetchQueue()]);
|
|
||||||
return boardResult;
|
|
||||||
},
|
|
||||||
[refetchBoard, refetchQueue],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
advActive,
|
advActive,
|
||||||
data: advActive ? searchData : boardData,
|
data: advActive ? searchData : boardData,
|
||||||
unassignedQueue: advActive ? EMPTY_UNASSIGNED_QUEUE : (queueData ?? EMPTY_UNASSIGNED_QUEUE),
|
|
||||||
unassignedError: advActive ? null : (queueError ?? null),
|
|
||||||
// SH-165: the pinned queue has its own first-load state. Folding it into
|
|
||||||
// isFetching only made "still paging board/search" indistinguishable from
|
|
||||||
// "zero unassigned", so the pin announced an empty queue while loading.
|
|
||||||
unassignedLoading: advActive ? false : queueLoading,
|
|
||||||
isLoading: advActive ? searchLoading : boardLoading,
|
isLoading: advActive ? searchLoading : boardLoading,
|
||||||
isFetching: advActive ? searchFetching : boardFetching || queueFetching,
|
isFetching: advActive ? searchFetching : boardFetching,
|
||||||
error: advActive ? searchError : boardError,
|
error: advActive ? searchError : boardError,
|
||||||
refetch: advActive ? refetchSearch : refetchBoardWithQueue,
|
refetch: advActive ? refetchSearch : refetchBoard,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
DEFAULT_DAY_PAGE_SIZE,
|
DEFAULT_DAY_PAGE_SIZE,
|
||||||
findItemPageIndex,
|
findItemPageIndex,
|
||||||
|
totalPageCount,
|
||||||
type DayPageSize,
|
type DayPageSize,
|
||||||
} from "@/domain/work-orders/utils/day-pagination";
|
} from "@/domain/work-orders/utils/day-pagination";
|
||||||
|
|
||||||
|
|
@ -44,6 +45,8 @@ export function useWorkOrdersDayPagination({
|
||||||
onRevealed(revealedId);
|
onRevealed(revealedId);
|
||||||
}, [pendingRevealId, dayItems, pageSize, onRevealed]);
|
}, [pendingRevealId, dayItems, pageSize, onRevealed]);
|
||||||
|
|
||||||
|
const pageCount = totalPageCount(dayItems.length, pageSize);
|
||||||
|
|
||||||
const setPageSize = useCallback((next: DayPageSize) => {
|
const setPageSize = useCallback((next: DayPageSize) => {
|
||||||
setPageSizeState(next);
|
setPageSizeState(next);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
|
|
@ -54,8 +57,8 @@ export function useWorkOrdersDayPagination({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const goToNextPage = useCallback(() => {
|
const goToNextPage = useCallback(() => {
|
||||||
setPage((current) => current + 1);
|
setPage((current) => Math.min(current + 1, Math.max(0, pageCount - 1)));
|
||||||
}, []);
|
}, [pageCount]);
|
||||||
|
|
||||||
const revealRow = useCallback((id: string | number) => {
|
const revealRow = useCallback((id: string | number) => {
|
||||||
setPendingRevealId(id);
|
setPendingRevealId(id);
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,8 @@ import {
|
||||||
countAdvancedFilters,
|
countAdvancedFilters,
|
||||||
getInitialAdvancedFilters,
|
getInitialAdvancedFilters,
|
||||||
} from "@/domain/work-orders/utils/advanced-filters-utils";
|
} from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||||
import {
|
import { toBoardQueryParams } from "@/domain/work-orders/utils/board-query-params";
|
||||||
BOARD_SEARCH_MAX_PAGE_SIZE,
|
import { asDayPageSize, DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination";
|
||||||
toBoardQueryParams,
|
|
||||||
} from "@/domain/work-orders/utils/board-query-params";
|
|
||||||
import { compactWeekLabel, currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
import { compactWeekLabel, currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||||
import {
|
import {
|
||||||
firstWeekday,
|
firstWeekday,
|
||||||
|
|
@ -27,7 +25,7 @@ export function useWorkOrdersListFilters() {
|
||||||
const [advOpen, setAdvOpen] = useState(false);
|
const [advOpen, setAdvOpen] = useState(false);
|
||||||
const [advApplied, setAdvApplied] = useState<WorkOrderAdvancedFilters | null>(null);
|
const [advApplied, setAdvApplied] = useState<WorkOrderAdvancedFilters | null>(null);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [pageSize, setPageSize] = useState(BOARD_SEARCH_MAX_PAGE_SIZE);
|
const [pageSize, setPageSizeState] = useState(DEFAULT_DAY_PAGE_SIZE);
|
||||||
const [sortBy, setSortBy] = useState("");
|
const [sortBy, setSortBy] = useState("");
|
||||||
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
|
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
|
||||||
const [weekMonday, setWeekMondayState] = useState(currentWeekMonday);
|
const [weekMonday, setWeekMondayState] = useState(currentWeekMonday);
|
||||||
|
|
@ -67,9 +65,7 @@ export function useWorkOrdersListFilters() {
|
||||||
setPage(0);
|
setPage(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setSelectedDayIso = (value: string) => {
|
const setSelectedDayIso = setSelectedDayIsoState;
|
||||||
setSelectedDayIsoState(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const navigateToDay = (dateIso: string) => {
|
const navigateToDay = (dateIso: string) => {
|
||||||
const tab = scheduledDayTab(dateIso);
|
const tab = scheduledDayTab(dateIso);
|
||||||
|
|
@ -110,6 +106,11 @@ export function useWorkOrdersListFilters() {
|
||||||
setPage(0);
|
setPage(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setPageSize = (next: number) => {
|
||||||
|
setPageSizeState(asDayPageSize(next));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSort = (column: string) => {
|
const handleSort = (column: string) => {
|
||||||
if (sortBy === column) {
|
if (sortBy === column) {
|
||||||
setSortDir((current) => {
|
setSortDir((current) => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||||
|
import { useSyncOutOfRangeSearchPage } from "@/app/(protected)/workorders/_hooks/use-sync-out-of-range-search-page";
|
||||||
import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source";
|
import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source";
|
||||||
import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination";
|
import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination";
|
||||||
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
||||||
|
|
@ -7,19 +8,16 @@ import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
import {
|
import {
|
||||||
|
pickBoardPager,
|
||||||
pageSelectedDayGroups,
|
pageSelectedDayGroups,
|
||||||
shouldShowDayPagination,
|
type DayPageSize,
|
||||||
} from "@/domain/work-orders/utils/day-pagination";
|
} from "@/domain/work-orders/utils/day-pagination";
|
||||||
import {
|
import {
|
||||||
buildSelectedDayGroup,
|
buildSelectedDayGroup,
|
||||||
isDayScopedSearchEmpty,
|
isDayScopedSearchEmpty,
|
||||||
isUnassignedQueueReady,
|
|
||||||
} from "@/domain/work-orders/utils/week-day-nav-utils";
|
} from "@/domain/work-orders/utils/week-day-nav-utils";
|
||||||
import {
|
import {
|
||||||
composeBoardSections,
|
|
||||||
filterByBarAndSearch,
|
filterByBarAndSearch,
|
||||||
mergeWeeklyAndUnassignedQueue,
|
|
||||||
rowIdSet,
|
|
||||||
splitScheduledUnscheduled,
|
splitScheduledUnscheduled,
|
||||||
} from "@/domain/work-orders/utils/table-filter-utils";
|
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||||
|
|
||||||
|
|
@ -28,30 +26,16 @@ export type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-
|
||||||
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
||||||
|
|
||||||
const EMPTY_WEEKLY_ITEMS: WorkOrderTableRow[] = [];
|
const EMPTY_WEEKLY_ITEMS: WorkOrderTableRow[] = [];
|
||||||
|
const EMPTY_UNASSIGNED: WorkOrderTableRow[] = [];
|
||||||
|
|
||||||
export function useWorkOrdersTableData(filters: FiltersReturn) {
|
export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
const {
|
const { advActive, data, isLoading, isFetching, error, refetch } =
|
||||||
advActive,
|
useWorkOrdersBoardSource(filters);
|
||||||
data,
|
|
||||||
unassignedQueue,
|
|
||||||
unassignedError,
|
|
||||||
unassignedLoading,
|
|
||||||
isLoading,
|
|
||||||
isFetching,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
} = useWorkOrdersBoardSource(filters);
|
|
||||||
|
|
||||||
const weeklyItems = data?.items ?? EMPTY_WEEKLY_ITEMS;
|
const weeklyItems = data?.items ?? EMPTY_WEEKLY_ITEMS;
|
||||||
const items = useMemo(
|
|
||||||
() => mergeWeeklyAndUnassignedQueue(weeklyItems, unassignedQueue),
|
|
||||||
[weeklyItems, unassignedQueue],
|
|
||||||
);
|
|
||||||
const weeklyIds = useMemo(() => rowIdSet(weeklyItems), [weeklyItems]);
|
|
||||||
const queueIds = useMemo(() => rowIdSet(unassignedQueue), [unassignedQueue]);
|
|
||||||
|
|
||||||
const local = useWorkOrdersTableLocalState({
|
const local = useWorkOrdersTableLocalState({
|
||||||
items,
|
items: weeklyItems,
|
||||||
weekMonday: filters.weekMonday,
|
weekMonday: filters.weekMonday,
|
||||||
advActive,
|
advActive,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
|
@ -70,18 +54,12 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
return displayRows;
|
return displayRows;
|
||||||
}, [displayRows, filters.barFilters, filters.search, advActive]);
|
}, [displayRows, filters.barFilters, filters.search, advActive]);
|
||||||
|
|
||||||
const { unassigned, scheduled, unscheduled } = useMemo(() => {
|
const { scheduled, unscheduled } = useMemo(() => {
|
||||||
if (advActive) {
|
if (advActive) {
|
||||||
const split = splitScheduledUnscheduled(advResults);
|
return splitScheduledUnscheduled(advResults);
|
||||||
return { unassigned: [], ...split };
|
|
||||||
}
|
}
|
||||||
return composeBoardSections({
|
return splitScheduledUnscheduled(filtered);
|
||||||
displayRows,
|
}, [advActive, advResults, filtered]);
|
||||||
weekRows: filtered,
|
|
||||||
weeklyIds,
|
|
||||||
queueIds,
|
|
||||||
});
|
|
||||||
}, [advActive, advResults, displayRows, filtered, weeklyIds, queueIds]);
|
|
||||||
|
|
||||||
const dayGroups = useMemo(
|
const dayGroups = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -105,16 +83,16 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
const searchActive = filters.search.trim() !== "";
|
const searchActive = filters.search.trim() !== "";
|
||||||
const displayGroups = pagedDay.groups;
|
const displayGroups = pagedDay.groups;
|
||||||
const dayTotalCount = pagedDay.totalCount;
|
const dayTotalCount = pagedDay.totalCount;
|
||||||
|
const dayVisibleCount = pagedDay.groups[0]?.items.length ?? 0;
|
||||||
const noSearchResults =
|
const noSearchResults =
|
||||||
!advActive &&
|
!advActive &&
|
||||||
isDayScopedSearchEmpty({
|
isDayScopedSearchEmpty({
|
||||||
searchActive,
|
searchActive,
|
||||||
unassignedCount: unassigned.length,
|
unassignedCount: 0,
|
||||||
dayItemCount: dayTotalCount,
|
dayItemCount: dayTotalCount,
|
||||||
unassignedQueueReady: isUnassignedQueueReady(unassignedLoading, unassignedError),
|
unassignedQueueReady: true,
|
||||||
});
|
});
|
||||||
const isEmptyWeek =
|
const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
|
||||||
!advActive && scheduled.length === 0 && unassigned.length === 0 && !searchActive;
|
|
||||||
|
|
||||||
const todayKey =
|
const todayKey =
|
||||||
filters.weekMonday === currentWeekMonday()
|
filters.weekMonday === currentWeekMonday()
|
||||||
|
|
@ -126,6 +104,35 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
[displayRows, local.selectedId],
|
[displayRows, local.selectedId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const scopeTotalCount = advActive ? (data?.totalCount ?? advResults.length) : dayTotalCount;
|
||||||
|
useSyncOutOfRangeSearchPage({
|
||||||
|
enabled: advActive,
|
||||||
|
ready: !isLoading,
|
||||||
|
page: filters.page,
|
||||||
|
pageSize: filters.pageSize,
|
||||||
|
totalCount: scopeTotalCount,
|
||||||
|
setPage: filters.setPage,
|
||||||
|
});
|
||||||
|
const listTotalCount = advActive ? advResults.length : dayVisibleCount;
|
||||||
|
const pager = pickBoardPager({
|
||||||
|
advActive,
|
||||||
|
isLoading,
|
||||||
|
noSearchResults,
|
||||||
|
advResultCount: advResults.length,
|
||||||
|
search: {
|
||||||
|
page: filters.page,
|
||||||
|
pageSize: filters.pageSize,
|
||||||
|
totalCount: scopeTotalCount,
|
||||||
|
},
|
||||||
|
day: {
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
pageIndex: pagedDay.pageIndex,
|
||||||
|
pageCount: pagedDay.pageCount,
|
||||||
|
itemCount: dayTotalCount,
|
||||||
|
footerLabel: displayGroups[0]?.label ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: displayRows,
|
rows: displayRows,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
@ -134,9 +141,10 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
refetch,
|
refetch,
|
||||||
advActive,
|
advActive,
|
||||||
advResults,
|
advResults,
|
||||||
unassigned,
|
unassigned: EMPTY_UNASSIGNED,
|
||||||
unassignedError,
|
unassignedTotalCount: 0,
|
||||||
unassignedLoading,
|
unassignedError: null,
|
||||||
|
unassignedLoading: false,
|
||||||
unscheduled,
|
unscheduled,
|
||||||
displayGroups,
|
displayGroups,
|
||||||
searchActive,
|
searchActive,
|
||||||
|
|
@ -145,22 +153,28 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
todayKey,
|
todayKey,
|
||||||
...localUi,
|
...localUi,
|
||||||
selectedRow,
|
selectedRow,
|
||||||
scopeTotalCount: data?.totalCount ?? 0,
|
scopeTotalCount,
|
||||||
totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayTotalCount,
|
totalCount: listTotalCount,
|
||||||
search: filters.search,
|
search: filters.search,
|
||||||
showDayPagination: shouldShowDayPagination({
|
showDayPagination: pager.show,
|
||||||
flatListMode: advActive,
|
dayPageSize: pager.pageSize,
|
||||||
isLoading,
|
dayPageIndex: pager.pageIndex,
|
||||||
noSearchResults,
|
dayPageCount: pager.pageCount,
|
||||||
}),
|
dayItemCount: pager.itemCount,
|
||||||
dayPageSize: pagination.pageSize,
|
dayFooterLabel: pager.footerLabel,
|
||||||
dayPageIndex: pagedDay.pageIndex,
|
setDayPageSize: (next: DayPageSize) => {
|
||||||
dayPageCount: pagedDay.pageCount,
|
if (advActive) {
|
||||||
dayItemCount: pagedDay.totalCount,
|
filters.setPageSize(next);
|
||||||
dayFooterLabel: displayGroups[0]?.label ?? "",
|
return;
|
||||||
setDayPageSize: pagination.setPageSize,
|
}
|
||||||
goToPrevDayPage: pagination.goToPrevPage,
|
pagination.setPageSize(next);
|
||||||
goToNextDayPage: pagination.goToNextPage,
|
},
|
||||||
|
goToPrevDayPage: advActive
|
||||||
|
? () => filters.setPage(Math.max(0, pager.pageIndex - 1))
|
||||||
|
: pagination.goToPrevPage,
|
||||||
|
goToNextDayPage: advActive
|
||||||
|
? () => filters.setPage(Math.min(pager.pageIndex + 1, Math.max(0, pager.pageCount - 1)))
|
||||||
|
: pagination.goToNextPage,
|
||||||
revealScheduledRow: pagination.revealRow,
|
revealScheduledRow: pagination.revealRow,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ export default function WorkOrdersListPage() {
|
||||||
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
surface
|
surface
|
||||||
eyebrow="OPERATIONS"
|
|
||||||
title="Work Orders"
|
title="Work Orders"
|
||||||
belowTitle={
|
belowTitle={
|
||||||
<DispatcherFilter
|
<DispatcherFilter
|
||||||
|
|
@ -68,7 +67,7 @@ export default function WorkOrdersListPage() {
|
||||||
{filters.advApplied != null && (
|
{filters.advApplied != null && (
|
||||||
<WorkOrdersAdvancedFiltersBanner
|
<WorkOrdersAdvancedFiltersBanner
|
||||||
applied={filters.advApplied}
|
applied={filters.advApplied}
|
||||||
totalCount={tableData.totalCount}
|
totalCount={tableData.scopeTotalCount}
|
||||||
sites={page.locations}
|
sites={page.locations}
|
||||||
users={page.users}
|
users={page.users}
|
||||||
onClearChip={filters.clearAdvancedFilterChip}
|
onClearChip={filters.clearAdvancedFilterChip}
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,10 @@ export function NavArrowButton({
|
||||||
type={type}
|
type={type}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-card transition-colors disabled:cursor-not-allowed",
|
"flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-card transition-colors",
|
||||||
disabled ? "text-border" : "text-muted-foreground",
|
disabled
|
||||||
|
? "cursor-not-allowed text-border"
|
||||||
|
: "cursor-pointer text-muted-foreground hover:bg-(--color-bg-muted) hover:text-foreground",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,11 @@
|
||||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderBoardListResult } from "@/domain/work-orders/types/work-order-board";
|
||||||
import { toUnassignedQueueQueryParams } from "@/domain/work-orders/utils/board-query-params";
|
import { toUnassignedQueueQueryParams } from "@/domain/work-orders/utils/board-query-params";
|
||||||
|
|
||||||
const UNASSIGNED_QUEUE_MAX_PAGES = 50;
|
/** One GET /board/search page for Dispatcher=Unassigned / Advanced Filters. */
|
||||||
|
export async function fetchUnassignedBoardPage(
|
||||||
/** Exhausts GET /board/search pages for the global Unassigned pin (SH-165). */
|
page = 0,
|
||||||
export async function fetchUnassignedBoardRows(): Promise<WorkOrderTableRow[]> {
|
pageSize = 50,
|
||||||
const rows: WorkOrderTableRow[] = [];
|
): Promise<WorkOrderBoardListResult> {
|
||||||
let page = 0;
|
return workOrdersApi.searchBoard(toUnassignedQueueQueryParams(page, pageSize));
|
||||||
let totalCount = Number.POSITIVE_INFINITY;
|
|
||||||
|
|
||||||
while (rows.length < totalCount) {
|
|
||||||
if (page >= UNASSIGNED_QUEUE_MAX_PAGES) {
|
|
||||||
throw new Error(
|
|
||||||
"Unable to load the unassigned queue: search result set exceeded the safety page limit.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const result = await workOrdersApi.searchBoard(toUnassignedQueueQueryParams(page));
|
|
||||||
rows.push(...result.items);
|
|
||||||
totalCount = result.totalCount;
|
|
||||||
if (result.items.length === 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
page += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -153,17 +153,17 @@ export const workOrdersApi = {
|
||||||
|
|
||||||
const scheduled = mapWorkOrderTableRows(payload.scheduled ?? []);
|
const scheduled = mapWorkOrderTableRows(payload.scheduled ?? []);
|
||||||
const unscheduled = mapWorkOrderTableRows(payload.unscheduled ?? []);
|
const unscheduled = mapWorkOrderTableRows(payload.unscheduled ?? []);
|
||||||
const returnedCount = payload.counts?.returned ?? scheduled.length;
|
const returnedCount = payload.counts?.returned || scheduled.length;
|
||||||
const totalCount = payload.counts?.total ?? returnedCount;
|
const totalCount = payload.counts?.total || scheduled.length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: [...unscheduled, ...scheduled],
|
items: scheduled,
|
||||||
scheduled,
|
scheduled,
|
||||||
unscheduled,
|
unscheduled,
|
||||||
totalCount,
|
totalCount,
|
||||||
returnedCount,
|
returnedCount,
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: scheduled.length + unscheduled.length,
|
pageSize: scheduled.length,
|
||||||
weekStart: payload.weekStart,
|
weekStart: payload.weekStart,
|
||||||
weekEnd: payload.weekEnd,
|
weekEnd: payload.weekEnd,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -63,4 +63,8 @@ export function assignWizardServicePocFields(
|
||||||
if (draft.severity != null) {
|
if (draft.severity != null) {
|
||||||
payload.severity = draft.severity;
|
payload.severity = draft.severity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (draft.avetaRequired) {
|
||||||
|
payload.avetaRequired = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,11 @@ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
|
||||||
{ key: "pm", field: "pm", toValue: (value) => value as string },
|
{ key: "pm", field: "pm", toValue: (value) => value as string },
|
||||||
{ key: "extraServices", field: "extraServices", toValue: (value) => JSON.stringify(value) },
|
{ key: "extraServices", field: "extraServices", toValue: (value) => JSON.stringify(value) },
|
||||||
{ key: "serviceNotes", field: "serviceNotes", toValue: (value) => value as string },
|
{ key: "serviceNotes", field: "serviceNotes", toValue: (value) => value as string },
|
||||||
|
{
|
||||||
|
key: "avetaRequired",
|
||||||
|
field: "avetaRequired",
|
||||||
|
toValue: (value) => (value ? "true" : "false"),
|
||||||
|
},
|
||||||
{ key: "flagColor", field: "flagColor", toValue: (value) => (value as string | null) ?? "" },
|
{ key: "flagColor", field: "flagColor", toValue: (value) => (value as string | null) ?? "" },
|
||||||
{
|
{
|
||||||
key: "additionalContacts",
|
key: "additionalContacts",
|
||||||
|
|
|
||||||
|
|
@ -99,11 +99,13 @@ function mapDocStatus(raw: unknown): CompletionDocStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapMediaCategory(raw: unknown): BoardMediaCategory {
|
function mapMediaCategory(raw: unknown): BoardMediaCategory {
|
||||||
|
if (raw === "Aveta" || raw === "aveta") return "Aveta";
|
||||||
const numeric = typeof raw === "number" ? raw : Number(raw);
|
const numeric = typeof raw === "number" ? raw : Number(raw);
|
||||||
if (numeric === BOARD_MEDIA_CATEGORY_API.Before) return "Before";
|
if (numeric === BOARD_MEDIA_CATEGORY_API.Before) return "Before";
|
||||||
if (numeric === BOARD_MEDIA_CATEGORY_API.After) return "After";
|
if (numeric === BOARD_MEDIA_CATEGORY_API.After) return "After";
|
||||||
if (numeric === BOARD_MEDIA_CATEGORY_API.Extra) return "Extra";
|
if (numeric === BOARD_MEDIA_CATEGORY_API.Extra) return "Extra";
|
||||||
if (numeric === BOARD_MEDIA_CATEGORY_API.Completion) return "Completion";
|
if (numeric === BOARD_MEDIA_CATEGORY_API.Completion) return "Completion";
|
||||||
|
if (numeric === BOARD_MEDIA_CATEGORY_API.Aveta) return "Aveta";
|
||||||
return "Extra";
|
return "Extra";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,7 +147,7 @@ export function mapBoardDetailAuditEntry(raw: unknown, index: number): WorkOrder
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UploadableMediaCategory = "Before" | "After" | "Extra";
|
export type UploadableMediaCategory = "Before" | "After" | "Extra" | "Aveta";
|
||||||
|
|
||||||
export function buildMediaFormData(file: File, category?: UploadableMediaCategory): FormData {
|
export function buildMediaFormData(file: File, category?: UploadableMediaCategory): FormData {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
|
|
@ -246,6 +248,7 @@ function classifyFileName(fileName: string, url: string): FileCategory {
|
||||||
}
|
}
|
||||||
|
|
||||||
function boardCategoryToMediaCategory(category: BoardMediaCategory): MediaCategory {
|
function boardCategoryToMediaCategory(category: BoardMediaCategory): MediaCategory {
|
||||||
|
if (category === "Aveta") return "Aveta";
|
||||||
if (category === "Extra") return "Other";
|
if (category === "Extra") return "Other";
|
||||||
if (category === "Completion") return "Report";
|
if (category === "Completion") return "Report";
|
||||||
return "Photo";
|
return "Photo";
|
||||||
|
|
|
||||||
|
|
@ -368,6 +368,8 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0,
|
rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0,
|
||||||
carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0,
|
carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0,
|
||||||
isAddOn: readIsAddOn(item),
|
isAddOn: readIsAddOn(item),
|
||||||
|
avetaRequired: readBool(item, "avetaRequired", "AvetaRequired"),
|
||||||
|
hasAvetaDocument: readBool(item, "hasAvetaDocument", "HasAvetaDocument"),
|
||||||
originalWeek: readString(item, "originalWeek", "OriginalWeek"),
|
originalWeek: readString(item, "originalWeek", "OriginalWeek"),
|
||||||
originalDate: toDateInputValue(readString(item, "originalDate", "OriginalDate")),
|
originalDate: toDateInputValue(readString(item, "originalDate", "OriginalDate")),
|
||||||
isPastDue: readBool(item, "isPastDue", "IsPastDue"),
|
isPastDue: readBool(item, "isPastDue", "IsPastDue"),
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ export interface WorkOrderAdvancedFilters {
|
||||||
rescheduled: boolean;
|
rescheduled: boolean;
|
||||||
carriedOver: boolean;
|
carriedOver: boolean;
|
||||||
addOn: boolean;
|
addOn: boolean;
|
||||||
|
avetaOnly: boolean;
|
||||||
pmTypes: Set<string>;
|
pmTypes: Set<string>;
|
||||||
vendorTechs: Set<string>;
|
vendorTechs: Set<string>;
|
||||||
docs: Set<CompletionDocFilter>;
|
docs: Set<CompletionDocFilter>;
|
||||||
|
|
@ -55,6 +56,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
||||||
rescheduled: false,
|
rescheduled: false,
|
||||||
carriedOver: false,
|
carriedOver: false,
|
||||||
addOn: false,
|
addOn: false,
|
||||||
|
avetaOnly: false,
|
||||||
pmTypes: new Set(),
|
pmTypes: new Set(),
|
||||||
vendorTechs: new Set(),
|
vendorTechs: new Set(),
|
||||||
docs: new Set(),
|
docs: new Set(),
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,19 @@ import type { WorkOrderAuditEntry, WorkOrderComment } from "./work-order-detail"
|
||||||
import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
|
import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
|
||||||
import type { CompletionDocStatus, WorkOrderTableRow } from "./work-order-table-row";
|
import type { CompletionDocStatus, WorkOrderTableRow } from "./work-order-table-row";
|
||||||
|
|
||||||
/** API media.category: 1=Before, 2=After, 3=Extra, 4=Completion */
|
/** API media.category: 1=Before, 2=After, 3=Extra, 4=Completion, 5=Aveta */
|
||||||
export const BOARD_MEDIA_CATEGORY_API = {
|
export const BOARD_MEDIA_CATEGORY_API = {
|
||||||
Before: 1,
|
Before: 1,
|
||||||
After: 2,
|
After: 2,
|
||||||
Extra: 3,
|
Extra: 3,
|
||||||
Completion: 4,
|
Completion: 4,
|
||||||
|
Aveta: 5,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type BoardMediaCategoryApi =
|
export type BoardMediaCategoryApi =
|
||||||
(typeof BOARD_MEDIA_CATEGORY_API)[keyof typeof BOARD_MEDIA_CATEGORY_API];
|
(typeof BOARD_MEDIA_CATEGORY_API)[keyof typeof BOARD_MEDIA_CATEGORY_API];
|
||||||
|
|
||||||
export type BoardMediaCategory = "Before" | "After" | "Extra" | "Completion";
|
export type BoardMediaCategory = "Before" | "After" | "Extra" | "Completion" | "Aveta";
|
||||||
|
|
||||||
export interface WorkOrderBoardDetailInfoApi {
|
export interface WorkOrderBoardDetailInfoApi {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -50,6 +51,10 @@ export interface WorkOrderBoardDetailInfoApi {
|
||||||
pm?: string | null;
|
pm?: string | null;
|
||||||
serviceNotes?: string | null;
|
serviceNotes?: string | null;
|
||||||
extraServices?: string[] | null;
|
extraServices?: string[] | null;
|
||||||
|
/** Per-WO Aveta compliance flag (Service & Notes). */
|
||||||
|
avetaRequired?: boolean | null;
|
||||||
|
/** True when an Extra Docs file is categorized Aveta. */
|
||||||
|
hasAvetaDocument?: boolean | null;
|
||||||
docStatus?: number | null;
|
docStatus?: number | null;
|
||||||
flagColor?: string | null;
|
flagColor?: string | null;
|
||||||
primaryDispatchId?: number | null;
|
primaryDispatchId?: number | null;
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,8 @@ export interface WorkOrderBoardCreatePayload {
|
||||||
extraServices?: string[];
|
extraServices?: string[];
|
||||||
/** Issue detail from Service & Notes (`pmNote` in draft). */
|
/** Issue detail from Service & Notes (`pmNote` in draft). */
|
||||||
serviceNotes?: string;
|
serviceNotes?: string;
|
||||||
|
/** Per-WO Aveta compliance flag from Service & Notes. */
|
||||||
|
avetaRequired?: boolean;
|
||||||
locationId?: number;
|
locationId?: number;
|
||||||
pocContactId?: number;
|
pocContactId?: number;
|
||||||
pocName?: string;
|
pocName?: string;
|
||||||
|
|
@ -142,6 +144,7 @@ export type WorkOrderBoardEditableField =
|
||||||
| "completedDate"
|
| "completedDate"
|
||||||
| "pm"
|
| "pm"
|
||||||
| "serviceNotes"
|
| "serviceNotes"
|
||||||
|
| "avetaRequired"
|
||||||
| "extraServices"
|
| "extraServices"
|
||||||
| "flagColor"
|
| "flagColor"
|
||||||
| "additionalContacts"
|
| "additionalContacts"
|
||||||
|
|
@ -155,6 +158,13 @@ export interface WorkOrderBoardRowApi {
|
||||||
carriedOver?: number;
|
carriedOver?: number;
|
||||||
/** Backend-authoritative Add-On indicator (SH-184; recalculated on schedule). */
|
/** Backend-authoritative Add-On indicator (SH-184; recalculated on schedule). */
|
||||||
isAddOn?: boolean | null;
|
isAddOn?: boolean | null;
|
||||||
|
/** Per-WO Aveta compliance flag (Service & Notes). */
|
||||||
|
avetaRequired?: boolean | null;
|
||||||
|
/** True when an Extra Docs file is categorized Aveta. */
|
||||||
|
hasAvetaDocument?: boolean | null;
|
||||||
|
/** Original schedule snapshot for Reschedule hover history. */
|
||||||
|
originalDate?: string | null;
|
||||||
|
originalWeek?: string | null;
|
||||||
workOrderType?: number | null;
|
workOrderType?: number | null;
|
||||||
isPastDue?: boolean;
|
isPastDue?: boolean;
|
||||||
siteCode?: string | null;
|
siteCode?: string | null;
|
||||||
|
|
@ -277,6 +287,8 @@ export interface WorkOrderBoardSearchQueryParams {
|
||||||
rescheduled?: boolean;
|
rescheduled?: boolean;
|
||||||
carriedOver?: boolean;
|
carriedOver?: boolean;
|
||||||
addOn?: boolean;
|
addOn?: boolean;
|
||||||
|
/** Only WOs flagged `avetaRequired`. */
|
||||||
|
avetaOnly?: boolean;
|
||||||
flagColors?: string[];
|
flagColors?: string[];
|
||||||
internalOnly?: boolean;
|
internalOnly?: boolean;
|
||||||
hasUplift?: boolean;
|
hasUplift?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export type FileCategory = "image" | "video" | "pdf" | "doc" | "other";
|
export type FileCategory = "image" | "video" | "pdf" | "doc" | "other";
|
||||||
|
|
||||||
export type MediaCategory =
|
export type MediaCategory =
|
||||||
"Photo" | "Video" | "Receipt" | "Quote" | "Invoice" | "Report" | "Other";
|
"Photo" | "Video" | "Receipt" | "Quote" | "Invoice" | "Report" | "Aveta" | "Other";
|
||||||
|
|
||||||
export interface MediaFile {
|
export interface MediaFile {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -9,8 +9,8 @@ export interface MediaFile {
|
||||||
fileType: FileCategory;
|
fileType: FileCategory;
|
||||||
url: string;
|
url: string;
|
||||||
category: MediaCategory;
|
category: MediaCategory;
|
||||||
/** Board purpose label (Before/After/Extra/Completion). Null/undefined = needs categorization. */
|
/** Board purpose label (Before/After/Extra/Completion/Aveta). Null/undefined = needs categorization. */
|
||||||
purpose?: "Before" | "After" | "Extra" | "Completion" | null;
|
purpose?: "Before" | "After" | "Extra" | "Completion" | "Aveta" | null;
|
||||||
/** True when media is a legacy Before/After/Completion column (not deletable). */
|
/** True when media is a legacy Before/After/Completion column (not deletable). */
|
||||||
isLegacy?: boolean;
|
isLegacy?: boolean;
|
||||||
/** 0–100 while uploading; undefined when idle/complete. */
|
/** 0–100 while uploading; undefined when idle/complete. */
|
||||||
|
|
@ -26,6 +26,7 @@ export const MEDIA_CATEGORIES: MediaCategory[] = [
|
||||||
"Quote",
|
"Quote",
|
||||||
"Invoice",
|
"Invoice",
|
||||||
"Report",
|
"Report",
|
||||||
|
"Aveta",
|
||||||
"Other",
|
"Other",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,10 @@ export interface WorkOrderTableRow {
|
||||||
* recalculated on schedule week moves / clear; FE only reflects API value.
|
* recalculated on schedule week moves / clear; FE only reflects API value.
|
||||||
*/
|
*/
|
||||||
isAddOn: boolean;
|
isAddOn: boolean;
|
||||||
|
/** Per-WO Aveta compliance flag from Service & Notes (not a site attribute). */
|
||||||
|
avetaRequired?: boolean;
|
||||||
|
/** True when an Extra Docs file is categorized Aveta. */
|
||||||
|
hasAvetaDocument?: boolean;
|
||||||
originalWeek: string;
|
originalWeek: string;
|
||||||
originalDate: string;
|
originalDate: string;
|
||||||
isPastDue: boolean;
|
isPastDue: boolean;
|
||||||
|
|
@ -87,6 +91,7 @@ export interface WorkOrderTableDraft {
|
||||||
pm: string;
|
pm: string;
|
||||||
serviceNotes: string;
|
serviceNotes: string;
|
||||||
extraServices: string[];
|
extraServices: string[];
|
||||||
|
avetaRequired?: boolean;
|
||||||
vendorId: string;
|
vendorId: string;
|
||||||
company: string;
|
company: string;
|
||||||
tech: string;
|
tech: string;
|
||||||
|
|
@ -118,6 +123,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
|
||||||
pm: "",
|
pm: "",
|
||||||
serviceNotes: "",
|
serviceNotes: "",
|
||||||
extraServices: [],
|
extraServices: [],
|
||||||
|
avetaRequired: false,
|
||||||
vendorId: "",
|
vendorId: "",
|
||||||
company: "",
|
company: "",
|
||||||
tech: "",
|
tech: "",
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ export interface WorkOrderWizardDraft {
|
||||||
extraServices: string[];
|
extraServices: string[];
|
||||||
/** Free-text note attached to the service (Service & Notes). */
|
/** Free-text note attached to the service (Service & Notes). */
|
||||||
pmNote: string;
|
pmNote: string;
|
||||||
|
/** Per-WO Aveta compliance flag from Service & Notes. */
|
||||||
|
avetaRequired: boolean;
|
||||||
vendorId: string;
|
vendorId: string;
|
||||||
vendorName: string;
|
vendorName: string;
|
||||||
techPhone: string;
|
techPhone: string;
|
||||||
|
|
@ -82,6 +84,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
||||||
pm: "",
|
pm: "",
|
||||||
extraServices: [],
|
extraServices: [],
|
||||||
pmNote: "",
|
pmNote: "",
|
||||||
|
avetaRequired: false,
|
||||||
vendorId: "",
|
vendorId: "",
|
||||||
vendorName: "",
|
vendorName: "",
|
||||||
techPhone: "",
|
techPhone: "",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||||
import { fetchUnassignedBoardRows } from "@/domain/work-orders/api/fetch-unassigned-board-rows";
|
|
||||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||||
import type {
|
import type {
|
||||||
WorkOrdersListParams,
|
WorkOrdersListParams,
|
||||||
|
|
@ -11,7 +10,6 @@ import type {
|
||||||
WorkOrderBoardQueryParams,
|
WorkOrderBoardQueryParams,
|
||||||
WorkOrderBoardSearchQueryParams,
|
WorkOrderBoardSearchQueryParams,
|
||||||
} from "@/domain/work-orders/types/work-order-board";
|
} from "@/domain/work-orders/types/work-order-board";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
export function useWorkOrdersList(
|
export function useWorkOrdersList(
|
||||||
|
|
@ -48,17 +46,6 @@ export function useWorkOrdersBoardSearch(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkOrdersUnassignedQueue(
|
|
||||||
enabled = true,
|
|
||||||
): UseQueryResult<WorkOrderTableRow[], Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: queryKeys.workOrders.unassignedQueue(),
|
|
||||||
queryFn: fetchUnassignedBoardRows,
|
|
||||||
enabled,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkOrderUsers(): UseQueryResult<WorkOrderUser[], Error> {
|
export function useWorkOrderUsers(): UseQueryResult<WorkOrderUser[], Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.workOrders.dispatchers(),
|
queryKey: queryKeys.workOrders.dispatchers(),
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,14 @@ export function appendIndicatorChips(
|
||||||
clear: (current) => ({ ...current, addOn: false }),
|
clear: (current) => ({ ...current, addOn: false }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (filters.avetaOnly) {
|
||||||
|
chips.push({
|
||||||
|
key: "aveta",
|
||||||
|
group: "Aveta",
|
||||||
|
value: "Required",
|
||||||
|
clear: (current) => ({ ...current, avetaOnly: false }),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function appendFlagAndInternalChips(
|
export function appendFlagAndInternalChips(
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,9 @@ const matchesRescheduled: AdvancedFilterPredicate = (row, filters) =>
|
||||||
const matchesCarriedOver: AdvancedFilterPredicate = (row, filters) =>
|
const matchesCarriedOver: AdvancedFilterPredicate = (row, filters) =>
|
||||||
!filters.carriedOver || row.carriedOver >= 2;
|
!filters.carriedOver || row.carriedOver >= 2;
|
||||||
|
|
||||||
|
const matchesAvetaOnly: AdvancedFilterPredicate = (row, filters) =>
|
||||||
|
!filters.avetaOnly || row.avetaRequired === true;
|
||||||
|
|
||||||
const matchesInternalOnly: AdvancedFilterPredicate = (row, filters) =>
|
const matchesInternalOnly: AdvancedFilterPredicate = (row, filters) =>
|
||||||
!filters.internalOnly || row.woNumber.toUpperCase().startsWith("SH");
|
!filters.internalOnly || row.woNumber.toUpperCase().startsWith("SH");
|
||||||
|
|
||||||
|
|
@ -68,6 +71,7 @@ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [
|
||||||
matchesDoc,
|
matchesDoc,
|
||||||
matchesRescheduled,
|
matchesRescheduled,
|
||||||
matchesCarriedOver,
|
matchesCarriedOver,
|
||||||
|
matchesAvetaOnly,
|
||||||
matchesInternalOnly,
|
matchesInternalOnly,
|
||||||
matchesFlagColors,
|
matchesFlagColors,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool
|
||||||
filters.rescheduled,
|
filters.rescheduled,
|
||||||
filters.carriedOver,
|
filters.carriedOver,
|
||||||
filters.addOn,
|
filters.addOn,
|
||||||
|
filters.avetaOnly,
|
||||||
Boolean(filters.pmTypes?.size),
|
Boolean(filters.pmTypes?.size),
|
||||||
Boolean(filters.vendorTechs?.size),
|
Boolean(filters.vendorTechs?.size),
|
||||||
Boolean(filters.docs?.size),
|
Boolean(filters.docs?.size),
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ function tryPastDueRule({ fieldKey, actionKey }: AuditActionRuleContext): string
|
||||||
fieldKey.includes("pastdue") || actionKey.includes("pastdue") || actionKey === "markedpastdue";
|
fieldKey.includes("pastdue") || actionKey.includes("pastdue") || actionKey === "markedpastdue";
|
||||||
if (!matches) return null;
|
if (!matches) return null;
|
||||||
|
|
||||||
return "WO marked as Past Due. Due date has passed without completion.";
|
return "WO marked as Past Due. Schedule On has passed without completion.";
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryCreatedRule({ fieldKey, actionKey }: AuditActionRuleContext): string | null {
|
function tryCreatedRule({ fieldKey, actionKey }: AuditActionRuleContext): string | null {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
|
||||||
import type { CompletionDocFilter } from "@/domain/work-orders/types/work-order-advanced-filters";
|
import type { CompletionDocFilter } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
export const BOARD_SEARCH_MAX_PAGE_SIZE = 100;
|
export const BOARD_SEARCH_MAX_PAGE_SIZE = 200;
|
||||||
|
|
||||||
/** Wide Custom window so applied advanced search is not pinned to ThisWeek. */
|
/** Wide Custom window so applied advanced search is not pinned to ThisWeek. */
|
||||||
export const ADVANCED_SEARCH_ALL_WEEKS_FROM = "2000-01-01";
|
export const ADVANCED_SEARCH_ALL_WEEKS_FROM = "2000-01-01";
|
||||||
|
|
@ -116,6 +116,7 @@ function mapSearchFacetParams(
|
||||||
| "rescheduled"
|
| "rescheduled"
|
||||||
| "carriedOver"
|
| "carriedOver"
|
||||||
| "addOn"
|
| "addOn"
|
||||||
|
| "avetaOnly"
|
||||||
| "flagColors"
|
| "flagColors"
|
||||||
| "internalOnly"
|
| "internalOnly"
|
||||||
| "hasUplift"
|
| "hasUplift"
|
||||||
|
|
@ -126,6 +127,7 @@ function mapSearchFacetParams(
|
||||||
rescheduled: filters.rescheduled || undefined,
|
rescheduled: filters.rescheduled || undefined,
|
||||||
carriedOver: filters.carriedOver || undefined,
|
carriedOver: filters.carriedOver || undefined,
|
||||||
addOn: filters.addOn || undefined,
|
addOn: filters.addOn || undefined,
|
||||||
|
avetaOnly: filters.avetaOnly || undefined,
|
||||||
flagColors: filters.flagColors?.size ? [...filters.flagColors] : undefined,
|
flagColors: filters.flagColors?.size ? [...filters.flagColors] : undefined,
|
||||||
internalOnly: filters.internalOnly || undefined,
|
internalOnly: filters.internalOnly || undefined,
|
||||||
hasUplift: filters.hasUplift || undefined,
|
hasUplift: filters.hasUplift || undefined,
|
||||||
|
|
@ -246,10 +248,13 @@ export function woTypeFilterIsOverdue(type: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Week-independent GET /board/search params for the pinned Unassigned queue. */
|
/** Week-independent GET /board/search params for the pinned Unassigned queue. */
|
||||||
export function toUnassignedQueueQueryParams(page = 0): WorkOrderBoardSearchQueryParams {
|
export function toUnassignedQueueQueryParams(
|
||||||
|
page = 0,
|
||||||
|
pageSize = BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||||
|
): WorkOrderBoardSearchQueryParams {
|
||||||
return {
|
return {
|
||||||
page,
|
page,
|
||||||
pageSize: BOARD_SEARCH_MAX_PAGE_SIZE,
|
pageSize: Math.min(pageSize || BOARD_SEARCH_MAX_PAGE_SIZE, BOARD_SEARCH_MAX_PAGE_SIZE),
|
||||||
dispatchers: [BOARD_DISPATCHER_UNASSIGNED],
|
dispatchers: [BOARD_DISPATCHER_UNASSIGNED],
|
||||||
statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES],
|
statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES],
|
||||||
datePreset: "Custom",
|
datePreset: "Custom",
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,24 @@ export function findItemPageIndex<T extends { id: string | number }>(
|
||||||
return pageIndexForItem(index, pageSize);
|
return pageIndexForItem(index, pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function totalPageCount(totalCount: number, pageSize: number): number {
|
||||||
|
const size = pageSize > 0 ? pageSize : DEFAULT_DAY_PAGE_SIZE;
|
||||||
|
return Math.max(1, Math.ceil(Math.max(0, totalCount) / size) || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asDayPageSize(value: number): DayPageSize {
|
||||||
|
return (DAY_PAGE_SIZE_OPTIONS as readonly number[]).includes(value)
|
||||||
|
? (value as DayPageSize)
|
||||||
|
: DEFAULT_DAY_PAGE_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
export function paginateItems<T>(
|
export function paginateItems<T>(
|
||||||
items: readonly T[],
|
items: readonly T[],
|
||||||
pageIndex: number,
|
pageIndex: number,
|
||||||
pageSize: number,
|
pageSize: number,
|
||||||
): PaginatedSlice<T> {
|
): PaginatedSlice<T> {
|
||||||
const size = pageSize > 0 ? pageSize : DEFAULT_DAY_PAGE_SIZE;
|
const size = pageSize > 0 ? pageSize : DEFAULT_DAY_PAGE_SIZE;
|
||||||
const pageCount = Math.max(1, Math.ceil(items.length / size) || 1);
|
const pageCount = totalPageCount(items.length, size);
|
||||||
const page = Math.min(Math.max(0, pageIndex), pageCount - 1);
|
const page = Math.min(Math.max(0, pageIndex), pageCount - 1);
|
||||||
const start = page * size;
|
const start = page * size;
|
||||||
return {
|
return {
|
||||||
|
|
@ -47,6 +58,8 @@ export function paginateItems<T>(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SEARCH_PAGER_FOOTER_LABEL = "Results";
|
||||||
|
|
||||||
export function shouldShowDayPagination(input: {
|
export function shouldShowDayPagination(input: {
|
||||||
flatListMode: boolean;
|
flatListMode: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|
@ -55,6 +68,85 @@ export function shouldShowDayPagination(input: {
|
||||||
return !input.flatListMode && !input.isLoading && !input.noSearchResults;
|
return !input.flatListMode && !input.isLoading && !input.noSearchResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldShowSearchPagination(input: {
|
||||||
|
isLoading: boolean;
|
||||||
|
hasResults: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return !input.isLoading && input.hasResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSearchPager(input: { page: number; pageSize: number; totalCount: number }): {
|
||||||
|
pageSize: DayPageSize;
|
||||||
|
pageIndex: number;
|
||||||
|
pageCount: number;
|
||||||
|
itemCount: number;
|
||||||
|
} {
|
||||||
|
const pageSize = asDayPageSize(input.pageSize);
|
||||||
|
const pageCount = totalPageCount(input.totalCount, pageSize);
|
||||||
|
return {
|
||||||
|
pageSize,
|
||||||
|
pageIndex: Math.min(Math.max(0, input.page), pageCount - 1),
|
||||||
|
pageCount,
|
||||||
|
itemCount: input.totalCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextSearchPageIfOutOfRange(input: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalCount: number;
|
||||||
|
}): number | null {
|
||||||
|
if (input.totalCount <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const pageCount = totalPageCount(input.totalCount, asDayPageSize(input.pageSize));
|
||||||
|
if (input.page < pageCount) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return pageCount - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickBoardPager(input: {
|
||||||
|
advActive: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
noSearchResults: boolean;
|
||||||
|
advResultCount: number;
|
||||||
|
search: { page: number; pageSize: number; totalCount: number };
|
||||||
|
day: {
|
||||||
|
pageSize: DayPageSize;
|
||||||
|
pageIndex: number;
|
||||||
|
pageCount: number;
|
||||||
|
itemCount: number;
|
||||||
|
footerLabel: string;
|
||||||
|
};
|
||||||
|
}): {
|
||||||
|
show: boolean;
|
||||||
|
pageSize: DayPageSize;
|
||||||
|
pageIndex: number;
|
||||||
|
pageCount: number;
|
||||||
|
itemCount: number;
|
||||||
|
footerLabel: string;
|
||||||
|
} {
|
||||||
|
if (input.advActive) {
|
||||||
|
return {
|
||||||
|
show: shouldShowSearchPagination({
|
||||||
|
isLoading: input.isLoading,
|
||||||
|
hasResults: input.search.totalCount > 0,
|
||||||
|
}),
|
||||||
|
...resolveSearchPager(input.search),
|
||||||
|
footerLabel: SEARCH_PAGER_FOOTER_LABEL,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
show: shouldShowDayPagination({
|
||||||
|
flatListMode: false,
|
||||||
|
isLoading: input.isLoading,
|
||||||
|
noSearchResults: input.noSearchResults,
|
||||||
|
}),
|
||||||
|
...input.day,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDayPaginationSummary(dayLabel: string, itemCount: number): string {
|
export function formatDayPaginationSummary(dayLabel: string, itemCount: number): string {
|
||||||
const label = dayLabel.trim().toUpperCase();
|
const label = dayLabel.trim().toUpperCase();
|
||||||
const noun = itemCount === 1 ? "item" : "items";
|
const noun = itemCount === 1 ? "item" : "items";
|
||||||
|
|
|
||||||
23
src/domain/work-orders/utils/extra-docs-aveta.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import type { MediaCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extra Docs Aveta is an explicit API media category (value 5).
|
||||||
|
* Upload stays Extra; only an explicit Aveta category counts as attached.
|
||||||
|
*/
|
||||||
|
export function resolveExtraDocCategory(file: MediaFile): MediaCategory {
|
||||||
|
if (file.purpose === "Aveta" || file.category === "Aveta") {
|
||||||
|
return "Aveta";
|
||||||
|
}
|
||||||
|
return file.category;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasExplicitAvetaFile(files: MediaFile[]): boolean {
|
||||||
|
return files.some((item) => resolveExtraDocCategory(item) === "Aveta");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAvetaDocumentAttached(
|
||||||
|
hasAvetaDocument: boolean | undefined,
|
||||||
|
files: MediaFile[] = [],
|
||||||
|
): boolean {
|
||||||
|
return Boolean(hasAvetaDocument) || hasExplicitAvetaFile(files);
|
||||||
|
}
|
||||||
16
src/domain/work-orders/utils/reschedule-indicator.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { fmtDate } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
||||||
|
export function formatRescheduleCountLabel(count: number): string {
|
||||||
|
return count === 1 ? "1 time" : `${count} times`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hover copy for the Reschedule pill (count ≥ 2). Uses originalDate when the API sends it. */
|
||||||
|
export function formatRescheduleHover(count: number, originalDate?: string): string {
|
||||||
|
const times = formatRescheduleCountLabel(count);
|
||||||
|
const base = `Rescheduled ${times} — this WO has had its date changed ${times}.`;
|
||||||
|
const formatted = originalDate ? fmtDate(originalDate) : "";
|
||||||
|
if (!formatted) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
return `${base} Original date: ${formatted}.`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
/** Extras overlay follows the unsaved Info draft while editing. */
|
||||||
|
export function resolveSlideOverAvetaRequired(
|
||||||
|
editing: boolean,
|
||||||
|
persisted: boolean | undefined,
|
||||||
|
draft: boolean | undefined,
|
||||||
|
): boolean {
|
||||||
|
return Boolean(editing ? draft : persisted);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
/** Design: cell shows Service & Notes layout when note or extras exist. */
|
/** Design: cell shows Service & Notes layout when note, extras, or Aveta exist. */
|
||||||
export function hasServiceNotesDetails(
|
export function hasServiceNotesDetails(
|
||||||
serviceNotes?: string | null,
|
serviceNotes?: string | null,
|
||||||
extraServices?: string[] | null,
|
extraServices?: string[] | null,
|
||||||
|
avetaRequired?: boolean | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
return Boolean(serviceNotes?.trim()) || (extraServices?.length ?? 0) > 0;
|
return (
|
||||||
|
Boolean(serviceNotes?.trim()) || (extraServices?.length ?? 0) > 0 || Boolean(avetaRequired)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Truncate note for the board cell subtitle (~30 chars, design). */
|
/** Truncate note for the board cell subtitle (~30 chars, design). */
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export const SLIDE_OVER_EDIT_KEYS = [
|
||||||
"pm",
|
"pm",
|
||||||
"serviceNotes",
|
"serviceNotes",
|
||||||
"extraServices",
|
"extraServices",
|
||||||
|
"avetaRequired",
|
||||||
"additionalContacts",
|
"additionalContacts",
|
||||||
"vendorId",
|
"vendorId",
|
||||||
// company/tech are display companions; board persists vendor via vendorId only
|
// company/tech are display companions; board persists vendor via vendorId only
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ export const queryKeys = {
|
||||||
[...queryKeys.workOrders.all, "board", params ?? {}] as const,
|
[...queryKeys.workOrders.all, "board", params ?? {}] as const,
|
||||||
boardSearch: (params?: WorkOrderBoardSearchQueryParams) =>
|
boardSearch: (params?: WorkOrderBoardSearchQueryParams) =>
|
||||||
[...queryKeys.workOrders.all, "boardSearch", params ?? {}] as const,
|
[...queryKeys.workOrders.all, "boardSearch", params ?? {}] as const,
|
||||||
unassignedQueue: () => [...queryKeys.workOrders.all, "unassignedQueue"] as const,
|
|
||||||
byLocation: (locationId: string | number, search = "") =>
|
byLocation: (locationId: string | number, search = "") =>
|
||||||
[...queryKeys.workOrders.all, "byLocation", locationId, search] as const,
|
[...queryKeys.workOrders.all, "byLocation", locationId, search] as const,
|
||||||
detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const,
|
detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const,
|
||||||
|
|
|
||||||
|
|
@ -39,12 +39,18 @@ describe("handlePaginatedResponse", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles plain arrays", () => {
|
it("reads total as an alias of totalCount", () => {
|
||||||
const result = handlePaginatedResponse([{ id: 1 }], { page: 1, pageSize: 10 });
|
const result = handlePaginatedResponse<{ id: number }>(
|
||||||
|
{
|
||||||
|
items: [{ id: 1 }],
|
||||||
|
total: 1778,
|
||||||
|
page: 0,
|
||||||
|
pageSize: 50,
|
||||||
|
},
|
||||||
|
{ page: 0, pageSize: 50 },
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.items).toEqual([{ id: 1 }]);
|
expect(result.totalCount).toBe(1778);
|
||||||
expect(result.totalCount).toBe(1);
|
expect(result.items).toHaveLength(1);
|
||||||
expect(result.page).toBe(1);
|
|
||||||
expect(result.pageSize).toBe(10);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { fireEvent, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
|
||||||
|
import { ServiceNotesDialog } from "@/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
describe("Aveta Service & Notes", () => {
|
||||||
|
it("shows the Aveta shield on the Service cell when required", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<PMTypeCell
|
||||||
|
value="HVAC"
|
||||||
|
pmTypes={["HVAC"]}
|
||||||
|
serviceNotes=""
|
||||||
|
extraServices={[]}
|
||||||
|
avetaRequired
|
||||||
|
onSave={vi.fn()}
|
||||||
|
onSaveDetails={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Aveta required")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the green Aveta shield when the document is attached", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<PMTypeCell
|
||||||
|
value="HVAC"
|
||||||
|
pmTypes={["HVAC"]}
|
||||||
|
serviceNotes=""
|
||||||
|
extraServices={[]}
|
||||||
|
avetaRequired
|
||||||
|
avetaAttached
|
||||||
|
onSave={vi.fn()}
|
||||||
|
onSaveDetails={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Aveta attached")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists avetaRequired from Service & Notes", () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
renderWithProviders(
|
||||||
|
<ServiceNotesDialog
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
pm="HVAC"
|
||||||
|
extraServices={[]}
|
||||||
|
serviceNotes=""
|
||||||
|
avetaRequired={false}
|
||||||
|
pmTypes={["HVAC"]}
|
||||||
|
onSave={onSave}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: /Requires Aveta documentation/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
pm: "HVAC",
|
||||||
|
avetaRequired: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -37,7 +37,7 @@ describe("DueDateCell", () => {
|
||||||
<DueDateCell type="Emergency" value="2026-07-15" onSave={onSave} />,
|
<DueDateCell type="Emergency" value="2026-07-15" onSave={onSave} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /07-15|7\/15|15/i }));
|
fireEvent.click(screen.getByRole("button", { name: "Due Date" }));
|
||||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { RescheduleCounterPill } from "@/app/(protected)/workorders/_components/list/table/cells/wo-counter-pills";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
describe("RescheduleCounterPill", () => {
|
||||||
|
it("hides the pill when the count is below 2", () => {
|
||||||
|
const { container } = renderWithProviders(<RescheduleCounterPill count={1} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a purple count pill with history tooltip from originalDate", () => {
|
||||||
|
renderWithProviders(<RescheduleCounterPill count={4} originalDate="2026-05-08" />);
|
||||||
|
const pill = screen.getByLabelText(/Rescheduled 4 times.*Original date: May 8, 2026/i);
|
||||||
|
expect(pill).toHaveStyle({ background: "#f5f3ff", color: "#7c3aed" });
|
||||||
|
expect(pill).toHaveTextContent("4");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { fireEvent, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { SlideOverInfoTabEditView } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view";
|
||||||
|
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
woNumber: "24871",
|
||||||
|
site: "BK5",
|
||||||
|
type: "Emergency",
|
||||||
|
severity: 1,
|
||||||
|
dispatcherId: "",
|
||||||
|
dispatcherName: "",
|
||||||
|
scheduledOn: "2026-06-16",
|
||||||
|
completedDate: "",
|
||||||
|
targetWeek: "",
|
||||||
|
dayGroup: "",
|
||||||
|
dayLabel: "",
|
||||||
|
location: "Dallas",
|
||||||
|
locationId: "1",
|
||||||
|
pm: "Scrubber Pit",
|
||||||
|
serviceNotes: "",
|
||||||
|
extraServices: [],
|
||||||
|
vendorId: "",
|
||||||
|
company: "",
|
||||||
|
tech: "",
|
||||||
|
techPhone: "",
|
||||||
|
apptStart: "",
|
||||||
|
apptEnd: "",
|
||||||
|
apptTime: "",
|
||||||
|
status: "Scheduled",
|
||||||
|
docStatus: "No",
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
|
dueDate: "2026-06-16",
|
||||||
|
rescheduleCount: 0,
|
||||||
|
carriedOver: 0,
|
||||||
|
isAddOn: false,
|
||||||
|
originalWeek: "",
|
||||||
|
originalDate: "",
|
||||||
|
isPastDue: false,
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
mediaCount: 0,
|
||||||
|
title: "",
|
||||||
|
priority: "",
|
||||||
|
primaryDispatchId: null,
|
||||||
|
rowVersion: "",
|
||||||
|
dispatchRowVersion: "",
|
||||||
|
flagColor: null,
|
||||||
|
upliftSummary: EMPTY_UPLIFT_SUMMARY,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("slide-over Emergency Due Date", () => {
|
||||||
|
it("opens the dedicated calendar dialog for Emergency", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<SlideOverInfoTabEditView
|
||||||
|
draft={row()}
|
||||||
|
users={[]}
|
||||||
|
sites={[]}
|
||||||
|
vendors={[]}
|
||||||
|
pmTypes={["Scrubber Pit"]}
|
||||||
|
allOrders={[]}
|
||||||
|
onDraftChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Due Date" }));
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("heading", { name: "Due Date" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { WorkOrderSlideOverTabs } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs";
|
||||||
|
import type { useWorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/use-work-order-slide-over";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
type SlideOverState = ReturnType<typeof useWorkOrderSlideOver>;
|
||||||
|
|
||||||
|
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
|
return {
|
||||||
|
id: 42,
|
||||||
|
woNumber: "WO-42",
|
||||||
|
site: "SEA1",
|
||||||
|
type: "PM",
|
||||||
|
severity: null,
|
||||||
|
dispatcherId: "u1",
|
||||||
|
dispatcherName: "Dispatcher",
|
||||||
|
scheduledOn: "2026-07-20",
|
||||||
|
completedDate: "",
|
||||||
|
targetWeek: "",
|
||||||
|
dayGroup: "07/20",
|
||||||
|
dayLabel: "Mon",
|
||||||
|
location: "Seattle",
|
||||||
|
locationId: "loc-1",
|
||||||
|
pm: "HVAC",
|
||||||
|
serviceNotes: "",
|
||||||
|
extraServices: [],
|
||||||
|
company: "Vendor Co",
|
||||||
|
tech: "Tech",
|
||||||
|
techPhone: "",
|
||||||
|
vendorId: "v1",
|
||||||
|
apptStart: "",
|
||||||
|
apptEnd: "",
|
||||||
|
apptTime: "",
|
||||||
|
status: "Scheduled",
|
||||||
|
docStatus: "No",
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
|
dueDate: "",
|
||||||
|
rescheduleCount: 0,
|
||||||
|
carriedOver: 0,
|
||||||
|
originalWeek: "",
|
||||||
|
originalDate: "",
|
||||||
|
isPastDue: false,
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
mediaCount: 0,
|
||||||
|
title: "PM — SEA1",
|
||||||
|
priority: "Sev 3",
|
||||||
|
primaryDispatchId: null,
|
||||||
|
rowVersion: "v1",
|
||||||
|
dispatchRowVersion: "",
|
||||||
|
flagColor: null,
|
||||||
|
isAddOn: false,
|
||||||
|
avetaRequired: false,
|
||||||
|
upliftSummary: { hasUplift: false, pendingCount: 0 },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extrasState(editing: boolean, persisted: boolean, draft: boolean): SlideOverState {
|
||||||
|
return {
|
||||||
|
error: null,
|
||||||
|
detailForRow: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
infoSource: baseRow({ avetaRequired: persisted }),
|
||||||
|
activeDraft: baseRow({ avetaRequired: draft }),
|
||||||
|
showDetailSkeleton: false,
|
||||||
|
errorMessage: "",
|
||||||
|
refetch: vi.fn(),
|
||||||
|
extrasUploads: [],
|
||||||
|
isExtrasReadOnly: true,
|
||||||
|
mediaPending: false,
|
||||||
|
uploadExtraFiles: vi.fn(),
|
||||||
|
removeMedia: vi.fn(),
|
||||||
|
editing,
|
||||||
|
} as unknown as SlideOverState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExtras(state: SlideOverState) {
|
||||||
|
return renderWithProviders(
|
||||||
|
<WorkOrderSlideOverTabs
|
||||||
|
tab="extras"
|
||||||
|
state={state}
|
||||||
|
users={[]}
|
||||||
|
sites={[]}
|
||||||
|
vendors={[]}
|
||||||
|
pmTypes={[]}
|
||||||
|
allOrders={[]}
|
||||||
|
onOpenCompletionDoc={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WorkOrderSlideOverTabs Extras Aveta draft", () => {
|
||||||
|
it("shows the Extras Aveta indicator from an unsaved false-to-true draft", () => {
|
||||||
|
renderExtras(extrasState(true, false, true));
|
||||||
|
|
||||||
|
expect(screen.getByText("Extra Documents")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Aveta")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the Extras Aveta indicator from an unsaved true-to-false draft", () => {
|
||||||
|
renderExtras(extrasState(true, true, false));
|
||||||
|
|
||||||
|
expect(screen.getByText("Extra Documents")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Aveta")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the persisted Aveta indicator when not editing", () => {
|
||||||
|
renderExtras(extrasState(false, true, false));
|
||||||
|
|
||||||
|
expect(screen.getByText("Aveta")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { fireEvent, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab";
|
||||||
|
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const photo: MediaFile = {
|
||||||
|
id: "m1",
|
||||||
|
name: "safety-form.pdf",
|
||||||
|
fileType: "pdf",
|
||||||
|
url: "blob:1",
|
||||||
|
category: "Report",
|
||||||
|
};
|
||||||
|
|
||||||
|
const avetaDoc: MediaFile = {
|
||||||
|
id: "m2",
|
||||||
|
name: "aveta-clearance.pdf",
|
||||||
|
fileType: "pdf",
|
||||||
|
url: "blob:2",
|
||||||
|
category: "Aveta",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("SlideOverExtrasTab Aveta", () => {
|
||||||
|
it("does not label the first extra document as Aveta when none is explicitly Aveta", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<SlideOverExtrasTab
|
||||||
|
uploads={[photo]}
|
||||||
|
avetaRequired
|
||||||
|
isReadOnly
|
||||||
|
onUpload={vi.fn()}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("safety-form.pdf")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Aveta")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels only an explicitly Aveta-categorized extra document", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<SlideOverExtrasTab
|
||||||
|
uploads={[photo, avetaDoc]}
|
||||||
|
avetaRequired
|
||||||
|
isReadOnly
|
||||||
|
onUpload={vi.fn()}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("safety-form.pdf")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("aveta-clearance.pdf")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Aveta").length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("categorizes an Extra file as Aveta without auto-labeling the first upload", () => {
|
||||||
|
const onCategorize = vi.fn();
|
||||||
|
renderWithProviders(
|
||||||
|
<SlideOverExtrasTab
|
||||||
|
uploads={[photo]}
|
||||||
|
avetaRequired
|
||||||
|
isReadOnly={false}
|
||||||
|
onUpload={vi.fn()}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
onCategorize={onCategorize}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Category for safety-form.pdf"), {
|
||||||
|
target: { value: "Aveta" },
|
||||||
|
});
|
||||||
|
expect(onCategorize).toHaveBeenCalledWith("m1", "Aveta");
|
||||||
|
expect(screen.queryByText("safety-form.pdf")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -143,4 +143,38 @@ describe("useSlideOverMediaState completion filter", () => {
|
||||||
expect(result.current.extrasUploads).toHaveLength(1);
|
expect(result.current.extrasUploads).toHaveLength(1);
|
||||||
expect(result.current.extrasUploads[0]?.purpose).toBe("Extra");
|
expect(result.current.extrasUploads[0]?.purpose).toBe("Extra");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes Aveta-categorized extra documents in extrasUploads", () => {
|
||||||
|
const detail = detailWithMedia([
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
category: "Extra",
|
||||||
|
url: "https://cdn.example.com/extra.pdf",
|
||||||
|
fileName: "extra.pdf",
|
||||||
|
uploadedAt: "2026-07-15T18:00:00.0000000Z",
|
||||||
|
isLegacy: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
category: "Aveta",
|
||||||
|
url: "https://cdn.example.com/aveta.pdf",
|
||||||
|
fileName: "aveta.pdf",
|
||||||
|
uploadedAt: "2026-07-15T18:00:00.0000000Z",
|
||||||
|
isLegacy: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useSlideOverMediaState({
|
||||||
|
detailForRow: detail,
|
||||||
|
uploadMediaMutation: idleMutation(),
|
||||||
|
deleteMediaMutation: idleMutation(),
|
||||||
|
categorizeMediaMutation: idleMutation(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.extrasUploads).toHaveLength(2);
|
||||||
|
expect(result.current.extrasUploads.map((item) => item.purpose)).toEqual(["Extra", "Aveta"]);
|
||||||
|
expect(result.current.extrasUploads[1]?.category).toBe("Aveta");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { useSyncOutOfRangeSearchPage } from "@/app/(protected)/workorders/_hooks/use-sync-out-of-range-search-page";
|
||||||
|
|
||||||
|
describe("useSyncOutOfRangeSearchPage", () => {
|
||||||
|
it("sets the last valid page when a search shrinks to one page", () => {
|
||||||
|
const setPage = vi.fn();
|
||||||
|
renderHook(() =>
|
||||||
|
useSyncOutOfRangeSearchPage({
|
||||||
|
enabled: true,
|
||||||
|
ready: true,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
totalCount: 40,
|
||||||
|
setPage,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(setPage).toHaveBeenCalledWith(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not sync while the search query is loading", () => {
|
||||||
|
const setPage = vi.fn();
|
||||||
|
renderHook(() =>
|
||||||
|
useSyncOutOfRangeSearchPage({
|
||||||
|
enabled: true,
|
||||||
|
ready: false,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
totalCount: 40,
|
||||||
|
setPage,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(setPage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not sync when the result set is empty", () => {
|
||||||
|
const setPage = vi.fn();
|
||||||
|
renderHook(() =>
|
||||||
|
useSyncOutOfRangeSearchPage({
|
||||||
|
enabled: true,
|
||||||
|
ready: true,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
totalCount: 0,
|
||||||
|
setPage,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(setPage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,11 +5,12 @@ import { DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination
|
||||||
|
|
||||||
describe("useWorkOrdersDayPagination (SH-241)", () => {
|
describe("useWorkOrdersDayPagination (SH-241)", () => {
|
||||||
const onRevealed = vi.fn();
|
const onRevealed = vi.fn();
|
||||||
|
const manyItems = Array.from({ length: 60 }, (_, index) => ({ id: `wo-${index}` }));
|
||||||
const base = {
|
const base = {
|
||||||
weekMonday: "2026-08-17",
|
weekMonday: "2026-08-17",
|
||||||
selectedDayIso: "2026-08-17",
|
selectedDayIso: "2026-08-17",
|
||||||
advActive: false,
|
advActive: false,
|
||||||
dayItems: [] as { id: string }[],
|
dayItems: manyItems,
|
||||||
onRevealed,
|
onRevealed,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -58,12 +59,19 @@ describe("useWorkOrdersDayPagination (SH-241)", () => {
|
||||||
expect(result.current.page).toBe(0);
|
expect(result.current.page).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not advance past the last page", () => {
|
||||||
|
const { result } = renderHook(() => useWorkOrdersDayPagination(base));
|
||||||
|
act(() => result.current.goToNextPage());
|
||||||
|
act(() => result.current.goToNextPage());
|
||||||
|
expect(result.current.page).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("selects the page that contains a revealed row beyond the first page", () => {
|
it("selects the page that contains a revealed row beyond the first page", () => {
|
||||||
const dayItems = Array.from({ length: 60 }, (_, index) => ({ id: `wo-${index}` }));
|
const dayItems = Array.from({ length: 60 }, (_, index) => ({ id: `wo-${index}` }));
|
||||||
const revealed = vi.fn();
|
const revealed = vi.fn();
|
||||||
const { result, rerender } = renderHook(
|
const { result, rerender } = renderHook(
|
||||||
(props: typeof base) => useWorkOrdersDayPagination(props),
|
(props: typeof base) => useWorkOrdersDayPagination(props),
|
||||||
{ initialProps: { ...base, onRevealed: revealed } },
|
{ initialProps: { ...base, dayItems: [] as { id: string }[], onRevealed: revealed } },
|
||||||
);
|
);
|
||||||
|
|
||||||
act(() => result.current.revealRow("wo-55"));
|
act(() => result.current.revealRow("wo-55"));
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ describe("WoDayPaginationFooter (SH-241)", () => {
|
||||||
expect(screen.getByText("MON · 06/01 · 60 items")).toBeInTheDocument();
|
expect(screen.getByText("MON · 06/01 · 60 items")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Page 1 of 2")).toBeInTheDocument();
|
expect(screen.getByText("Page 1 of 2")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled();
|
||||||
|
expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled();
|
||||||
await user.selectOptions(screen.getByLabelText("Rows per page"), "25");
|
await user.selectOptions(screen.getByLabelText("Rows per page"), "25");
|
||||||
expect(onPageSizeChange).toHaveBeenCalledWith(25);
|
expect(onPageSizeChange).toHaveBeenCalledWith(25);
|
||||||
await user.click(screen.getByRole("button", { name: "Next page" }));
|
await user.click(screen.getByRole("button", { name: "Next page" }));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,247 @@
|
||||||
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { WorkOrderBoardSearchQueryParams } from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const { searchBoard, getBoard, patchBoardField, emptyList } = vi.hoisted(() => ({
|
||||||
|
searchBoard: vi.fn(),
|
||||||
|
getBoard: vi.fn(),
|
||||||
|
patchBoardField: vi.fn(),
|
||||||
|
emptyList: [] as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
|
||||||
|
workOrdersApi: {
|
||||||
|
searchBoard: (...args: unknown[]) => searchBoard(...args),
|
||||||
|
getBoard: (...args: unknown[]) => getBoard(...args),
|
||||||
|
patchBoardField: (...args: unknown[]) => patchBoardField(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||||
|
useWorkOrderLocations: () => ({ data: emptyList }),
|
||||||
|
useWorkOrderVendors: () => ({ data: emptyList }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/locations/use-cases/use-location-sites", () => ({
|
||||||
|
useLocationSites: () => ({ data: emptyList }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/(protected)/workorders/_components/list/table/use-wo-table-inline-create", () => ({
|
||||||
|
useWoTableInlineCreate: () => ({ handleInlineSave: vi.fn(), saving: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||||
|
|
||||||
|
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
|
||||||
|
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||||
|
import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
||||||
|
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
const INITIAL_TOTAL = 26;
|
||||||
|
const SHRUNK_TOTAL = 20;
|
||||||
|
|
||||||
|
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
|
return {
|
||||||
|
id: "1",
|
||||||
|
woNumber: "WO-1",
|
||||||
|
site: "CLV2",
|
||||||
|
type: "PM",
|
||||||
|
severity: null,
|
||||||
|
dispatcherId: "",
|
||||||
|
dispatcherName: "",
|
||||||
|
scheduledOn: "2026-08-17",
|
||||||
|
completedDate: "",
|
||||||
|
targetWeek: "",
|
||||||
|
dayGroup: "",
|
||||||
|
dayLabel: "",
|
||||||
|
location: "Cleveland, OH",
|
||||||
|
locationId: "",
|
||||||
|
pm: "Backflow",
|
||||||
|
serviceNotes: "",
|
||||||
|
extraServices: [],
|
||||||
|
company: "",
|
||||||
|
tech: "",
|
||||||
|
techPhone: "",
|
||||||
|
apptStart: "",
|
||||||
|
apptEnd: "",
|
||||||
|
apptTime: "—",
|
||||||
|
status: "Incomplete",
|
||||||
|
docStatus: "No",
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
pocNotes: "",
|
||||||
|
dueDate: "",
|
||||||
|
rescheduleCount: 0,
|
||||||
|
carriedOver: 0,
|
||||||
|
isAddOn: false,
|
||||||
|
originalWeek: "",
|
||||||
|
originalDate: "",
|
||||||
|
isPastDue: false,
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
mediaCount: 0,
|
||||||
|
title: "",
|
||||||
|
priority: "",
|
||||||
|
vendorId: "",
|
||||||
|
primaryDispatchId: null,
|
||||||
|
rowVersion: "v1",
|
||||||
|
dispatchRowVersion: "",
|
||||||
|
flagColor: null,
|
||||||
|
additionalContacts: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyBoardResult() {
|
||||||
|
return {
|
||||||
|
items: [] as WorkOrderTableRow[],
|
||||||
|
scheduled: [] as WorkOrderTableRow[],
|
||||||
|
unscheduled: [] as WorkOrderTableRow[],
|
||||||
|
totalCount: 0,
|
||||||
|
returnedCount: 0,
|
||||||
|
page: 0,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pagedResult(rows: WorkOrderTableRow[], page: number, pageSize: number) {
|
||||||
|
const items = rows.slice(page * pageSize, page * pageSize + pageSize);
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
scheduled: items,
|
||||||
|
unscheduled: [] as WorkOrderTableRow[],
|
||||||
|
totalCount: rows.length,
|
||||||
|
returnedCount: items.length,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pagesAfter(callCount: number): number[] {
|
||||||
|
return searchBoard.mock.calls.slice(callCount).map((call) => {
|
||||||
|
const params = call[0] as WorkOrderBoardSearchQueryParams;
|
||||||
|
return params.page ?? 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdvSearchPageSyncHarness() {
|
||||||
|
const filters = useWorkOrdersListFilters();
|
||||||
|
const tableData = useWorkOrdersTableData(filters);
|
||||||
|
const mutations = useWorkOrderTableMutations({
|
||||||
|
onPatch: tableData.patchRow,
|
||||||
|
clearPatch: tableData.clearRowPatch,
|
||||||
|
getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div data-testid="harness-state">
|
||||||
|
{`adv=${String(tableData.advActive)} rows=${tableData.advResults.length} total=${tableData.scopeTotalCount} page=${filters.page}`}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
filters.setPageSize(PAGE_SIZE);
|
||||||
|
filters.applyAdvancedFilters({ ...EMPTY_ADVANCED_FILTERS, rescheduled: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Apply advanced filters
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const row = tableData.rows[0];
|
||||||
|
if (row) {
|
||||||
|
void mutations.patchFieldAsync(row.id, { serviceNotes: "after-mutation" });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save row patch
|
||||||
|
</button>
|
||||||
|
<WoTable
|
||||||
|
tableData={tableData}
|
||||||
|
users={[]}
|
||||||
|
sites={[]}
|
||||||
|
vendors={[]}
|
||||||
|
pmTypes={[]}
|
||||||
|
weekMonday={filters.weekMonday}
|
||||||
|
onNavigateToDay={filters.navigateToDay}
|
||||||
|
onOpenSlideOver={() => undefined}
|
||||||
|
onOpenMedia={() => undefined}
|
||||||
|
onPatch={(id, patch) => {
|
||||||
|
void mutations.patchFieldAsync(id, patch);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("advanced search page sync after mutation", () => {
|
||||||
|
let serverRows: WorkOrderTableRow[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
serverRows = Array.from({ length: INITIAL_TOTAL }, (_, index) =>
|
||||||
|
makeRow({ id: `wo-${index}`, woNumber: `WO-${index}` }),
|
||||||
|
);
|
||||||
|
searchBoard.mockReset();
|
||||||
|
getBoard.mockReset();
|
||||||
|
patchBoardField.mockReset();
|
||||||
|
getBoard.mockResolvedValue(emptyBoardResult());
|
||||||
|
searchBoard.mockImplementation(async (params: WorkOrderBoardSearchQueryParams) =>
|
||||||
|
pagedResult(serverRows, params.page ?? 0, params.pageSize ?? PAGE_SIZE),
|
||||||
|
);
|
||||||
|
patchBoardField.mockImplementation(async (id: string | number) => {
|
||||||
|
serverRows = serverRows.slice(0, SHRUNK_TOTAL);
|
||||||
|
return makeRow({
|
||||||
|
id,
|
||||||
|
woNumber: `WO-${String(id).replace("wo-", "")}`,
|
||||||
|
serviceNotes: "after-mutation",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refetches the last valid search page after a mutation shrinks results to one page", async () => {
|
||||||
|
renderWithProviders(<AdvSearchPageSyncHarness />, { withAuth: false });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply advanced filters" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("harness-state")).toHaveTextContent(
|
||||||
|
"adv=true rows=25 total=26 page=0",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(screen.getByText("WO-0")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Page 1 of 2")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("WO-25")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("harness-state")).toHaveTextContent("page=1");
|
||||||
|
expect(screen.getByText("WO-25")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Page 2 of 2")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const callsBeforeMutation = searchBoard.mock.calls.length;
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save row patch" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(patchBoardField).toHaveBeenCalled();
|
||||||
|
expect(pagesAfter(callsBeforeMutation)).toContain(1);
|
||||||
|
expect(pagesAfter(callsBeforeMutation)).toContain(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("harness-state")).toHaveTextContent(
|
||||||
|
"adv=true rows=20 total=20 page=0",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Page 1 of 1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("WO-0")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("WO-25")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("RESULTS · 20 items")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled();
|
||||||
|
expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled();
|
||||||
|
}, 15000);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { fireEvent, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
|
||||||
|
import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
vi.mock("@/domain/locations/use-cases/use-location-sites", () => ({
|
||||||
|
useLocationSites: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/(protected)/workorders/_components/list/table/use-wo-table-inline-create", () => ({
|
||||||
|
useWoTableInlineCreate: () => ({ handleInlineSave: vi.fn(), saving: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
|
return {
|
||||||
|
id: "1",
|
||||||
|
woNumber: "24991",
|
||||||
|
site: "CLV2",
|
||||||
|
type: "PM",
|
||||||
|
severity: null,
|
||||||
|
dispatcherId: "",
|
||||||
|
dispatcherName: "",
|
||||||
|
scheduledOn: "",
|
||||||
|
completedDate: "",
|
||||||
|
targetWeek: "",
|
||||||
|
dayGroup: "",
|
||||||
|
dayLabel: "",
|
||||||
|
location: "Cleveland, OH",
|
||||||
|
locationId: "",
|
||||||
|
pm: "Backflow",
|
||||||
|
serviceNotes: "",
|
||||||
|
extraServices: [],
|
||||||
|
company: "",
|
||||||
|
tech: "",
|
||||||
|
techPhone: "",
|
||||||
|
apptStart: "",
|
||||||
|
apptEnd: "",
|
||||||
|
apptTime: "—",
|
||||||
|
status: "Incomplete",
|
||||||
|
docStatus: "No",
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
pocNotes: "",
|
||||||
|
dueDate: "",
|
||||||
|
rescheduleCount: 0,
|
||||||
|
carriedOver: 0,
|
||||||
|
isAddOn: false,
|
||||||
|
originalWeek: "",
|
||||||
|
originalDate: "",
|
||||||
|
isPastDue: false,
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
mediaCount: 0,
|
||||||
|
title: "",
|
||||||
|
priority: "",
|
||||||
|
vendorId: "",
|
||||||
|
primaryDispatchId: null,
|
||||||
|
rowVersion: "",
|
||||||
|
dispatchRowVersion: "",
|
||||||
|
flagColor: null,
|
||||||
|
additionalContacts: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableData(
|
||||||
|
overrides: Partial<ReturnTypeOfUseWorkOrdersTableData> = {},
|
||||||
|
): ReturnTypeOfUseWorkOrdersTableData {
|
||||||
|
const pageRows = [
|
||||||
|
makeRow({ id: "wo-0", woNumber: "WO-0" }),
|
||||||
|
makeRow({ id: "wo-49", woNumber: "WO-49" }),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
rows: pageRows,
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
advActive: true,
|
||||||
|
advResults: pageRows,
|
||||||
|
unassigned: [],
|
||||||
|
unassignedError: null,
|
||||||
|
unassignedLoading: false,
|
||||||
|
unscheduled: [],
|
||||||
|
displayGroups: [],
|
||||||
|
searchActive: false,
|
||||||
|
noSearchResults: false,
|
||||||
|
isEmptyWeek: false,
|
||||||
|
todayKey: null,
|
||||||
|
draftDayKey: null,
|
||||||
|
draft: { locationId: "", site: "" },
|
||||||
|
setDraft: vi.fn(),
|
||||||
|
unassignedOpen: true,
|
||||||
|
setUnassignedOpen: vi.fn(),
|
||||||
|
unschedOpen: true,
|
||||||
|
setUnschedOpen: vi.fn(),
|
||||||
|
selectedId: null,
|
||||||
|
setSelectedId: vi.fn(),
|
||||||
|
selectedRow: null,
|
||||||
|
flashId: null,
|
||||||
|
triggerFlash: vi.fn(),
|
||||||
|
revealScheduledRow: vi.fn(),
|
||||||
|
dragViz: { dragId: null, overId: null },
|
||||||
|
setDragViz: vi.fn(),
|
||||||
|
dragState: { current: { dragId: null, overId: null } },
|
||||||
|
patchRow: vi.fn(),
|
||||||
|
clearRowPatch: vi.fn(),
|
||||||
|
reorderRows: vi.fn(),
|
||||||
|
endDrag: vi.fn(),
|
||||||
|
startDayDraft: vi.fn(),
|
||||||
|
cancelDraft: vi.fn(),
|
||||||
|
addRow: vi.fn(),
|
||||||
|
scopeTotalCount: 1778,
|
||||||
|
totalCount: 2,
|
||||||
|
search: "",
|
||||||
|
showDayPagination: true,
|
||||||
|
dayPageSize: 50,
|
||||||
|
dayPageIndex: 0,
|
||||||
|
dayPageCount: 36,
|
||||||
|
dayItemCount: 1778,
|
||||||
|
dayFooterLabel: "Results",
|
||||||
|
setDayPageSize: vi.fn(),
|
||||||
|
goToPrevDayPage: vi.fn(),
|
||||||
|
goToNextDayPage: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
} as ReturnTypeOfUseWorkOrdersTableData;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WoTable advanced-search pagination", () => {
|
||||||
|
it("pages the flat Unassigned search without a pin or a 1778-row dump", () => {
|
||||||
|
const goToNextDayPage = vi.fn();
|
||||||
|
const data = tableData({ goToNextDayPage });
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<WoTable
|
||||||
|
tableData={data}
|
||||||
|
users={[]}
|
||||||
|
sites={[]}
|
||||||
|
vendors={[]}
|
||||||
|
pmTypes={[]}
|
||||||
|
weekMonday="2026-05-18"
|
||||||
|
onNavigateToDay={vi.fn()}
|
||||||
|
onOpenSlideOver={vi.fn()}
|
||||||
|
onOpenMedia={vi.fn()}
|
||||||
|
onPatch={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /Unassigned/ })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("WO-0")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("WO-49")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("WO-50")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("RESULTS · 1778 items")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Page 1 of 36")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
|
||||||
|
expect(goToNextDayPage).toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled();
|
||||||
|
expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the search pager when the current page is empty but matches remain", () => {
|
||||||
|
const goToPrevDayPage = vi.fn();
|
||||||
|
const data = tableData({
|
||||||
|
rows: [],
|
||||||
|
advResults: [],
|
||||||
|
totalCount: 0,
|
||||||
|
scopeTotalCount: 1778,
|
||||||
|
dayPageIndex: 35,
|
||||||
|
dayPageCount: 36,
|
||||||
|
goToPrevDayPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<WoTable
|
||||||
|
tableData={data}
|
||||||
|
users={[]}
|
||||||
|
sites={[]}
|
||||||
|
vendors={[]}
|
||||||
|
pmTypes={[]}
|
||||||
|
weekMonday="2026-05-18"
|
||||||
|
onNavigateToDay={vi.fn()}
|
||||||
|
onOpenSlideOver={vi.fn()}
|
||||||
|
onOpenMedia={vi.fn()}
|
||||||
|
onPatch={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText("No work orders match these filters")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("RESULTS · 1778 items")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Page 36 of 36")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Previous page" })).toBeEnabled();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Previous page" }));
|
||||||
|
expect(goToPrevDayPage).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveFirstCellShadow } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-style";
|
||||||
|
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
|
return {
|
||||||
|
id: "1",
|
||||||
|
woNumber: "24877",
|
||||||
|
site: "BK5",
|
||||||
|
type: "PM",
|
||||||
|
severity: null,
|
||||||
|
dispatcherId: "",
|
||||||
|
dispatcherName: "",
|
||||||
|
scheduledOn: "",
|
||||||
|
completedDate: "",
|
||||||
|
targetWeek: "",
|
||||||
|
dayGroup: "",
|
||||||
|
dayLabel: "",
|
||||||
|
location: "",
|
||||||
|
locationId: "",
|
||||||
|
pm: "",
|
||||||
|
serviceNotes: "",
|
||||||
|
extraServices: [],
|
||||||
|
vendorId: "",
|
||||||
|
company: "",
|
||||||
|
tech: "",
|
||||||
|
techPhone: "",
|
||||||
|
apptStart: "",
|
||||||
|
apptEnd: "",
|
||||||
|
apptTime: "",
|
||||||
|
status: "Scheduled",
|
||||||
|
docStatus: "",
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
|
dueDate: "",
|
||||||
|
rescheduleCount: 0,
|
||||||
|
carriedOver: 0,
|
||||||
|
isAddOn: false,
|
||||||
|
originalWeek: "",
|
||||||
|
originalDate: "",
|
||||||
|
isPastDue: false,
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
mediaCount: 0,
|
||||||
|
title: "",
|
||||||
|
priority: "",
|
||||||
|
primaryDispatchId: null,
|
||||||
|
rowVersion: "",
|
||||||
|
dispatchRowVersion: "",
|
||||||
|
flagColor: null,
|
||||||
|
upliftSummary: EMPTY_UPLIFT_SUMMARY,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveFirstCellShadow", () => {
|
||||||
|
it("uses a red inset for Emergency over Past Due", () => {
|
||||||
|
expect(resolveFirstCellShadow(row({ type: "Emergency", isPastDue: true }))).toBe(
|
||||||
|
"inset 3px 0 0 var(--destructive)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a warning inset for Past Due when the type is not Emergency", () => {
|
||||||
|
expect(resolveFirstCellShadow(row({ isPastDue: true }))).toBe("inset 3px 0 0 var(--warning)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no inset for a regular row", () => {
|
||||||
|
expect(resolveFirstCellShadow(row())).toBe("none");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -89,36 +89,12 @@ function renderTable(data: ReturnTypeOfUseWorkOrdersTableData) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("WoTable search empty vs Unassigned queue (SH-165/SH-241)", () => {
|
describe("WoTable search empty (SH-241)", () => {
|
||||||
it("shows empty search only after the Unassigned queue has settled empty", () => {
|
it("shows empty search when the selected day has no matches", () => {
|
||||||
const { getByText, queryByText } = renderTable(tableData());
|
const { getByText, queryByRole } = renderTable(tableData());
|
||||||
|
|
||||||
expect(getByText("No work orders match your search")).toBeInTheDocument();
|
expect(getByText("No work orders match your search")).toBeInTheDocument();
|
||||||
expect(queryByText(/Loading unassigned work orders/)).not.toBeInTheDocument();
|
expect(queryByRole("button", { name: /Unassigned/ })).not.toBeInTheDocument();
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps Unassigned loading visible instead of a premature empty search", () => {
|
|
||||||
const { getByText, queryByText } = renderTable(
|
|
||||||
tableData({
|
|
||||||
noSearchResults: true,
|
|
||||||
unassignedLoading: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(queryByText("No work orders match your search")).not.toBeInTheDocument();
|
|
||||||
expect(getByText(/Loading unassigned work orders/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps Unassigned error visible instead of hiding it behind empty search", () => {
|
|
||||||
const { getByText, queryByText } = renderTable(
|
|
||||||
tableData({
|
|
||||||
noSearchResults: true,
|
|
||||||
unassignedError: new Error("queue unavailable"),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(queryByText("No work orders match your search")).not.toBeInTheDocument();
|
|
||||||
expect(getByText("queue unavailable")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows empty advanced-filter copy with Adjust filters", () => {
|
it("shows empty advanced-filter copy with Adjust filters", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,12 @@
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { useState } from "react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell";
|
import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell";
|
||||||
import { WoTableBody } from "@/app/(protected)/workorders/_components/list/table/wo-table-body";
|
import { WoTableBody } from "@/app/(protected)/workorders/_components/list/table/wo-table-body";
|
||||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types";
|
import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types";
|
||||||
import { expandTablePatchToBoardFieldOps } from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
|
import { expandTablePatchToBoardFieldOps } from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import {
|
|
||||||
buildDayGroups,
|
|
||||||
composeBoardSections,
|
|
||||||
rowIdSet,
|
|
||||||
} from "@/domain/work-orders/utils/table-filter-utils";
|
|
||||||
|
|
||||||
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
return {
|
return {
|
||||||
|
|
@ -115,7 +110,7 @@ function baseTableData(
|
||||||
|
|
||||||
const DISPATCHER = { id: "sarah", name: "Sarah Chen", initials: "SC", color: "#3366cc" };
|
const DISPATCHER = { id: "sarah", name: "Sarah Chen", initials: "SC", color: "#3366cc" };
|
||||||
|
|
||||||
function UnassignedAssignHarness({
|
function DayAssignHarness({
|
||||||
initialRow,
|
initialRow,
|
||||||
patchBoardField,
|
patchBoardField,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -125,49 +120,38 @@ function UnassignedAssignHarness({
|
||||||
payload: { field: string; value?: string | null },
|
payload: { field: string; value?: string | null },
|
||||||
) => Promise<WorkOrderTableRow>;
|
) => Promise<WorkOrderTableRow>;
|
||||||
}) {
|
}) {
|
||||||
const [rows, setRows] = useState([initialRow]);
|
const [row, setRow] = useState(initialRow);
|
||||||
const weeklyIds = rowIdSet(rows);
|
|
||||||
const sections = useMemo(
|
|
||||||
() =>
|
|
||||||
composeBoardSections({
|
|
||||||
displayRows: rows,
|
|
||||||
weekRows: rows,
|
|
||||||
weeklyIds,
|
|
||||||
queueIds: weeklyIds,
|
|
||||||
}),
|
|
||||||
[rows, weeklyIds],
|
|
||||||
);
|
|
||||||
const tableData = baseTableData({
|
const tableData = baseTableData({
|
||||||
unassigned: sections.unassigned,
|
displayGroups: [
|
||||||
unscheduled: sections.unscheduled,
|
{
|
||||||
displayGroups: buildDayGroups(sections.scheduled),
|
key: "06/02",
|
||||||
|
label: "TUE · 06/02",
|
||||||
|
date: "2026-06-02",
|
||||||
|
items: [row],
|
||||||
|
totalCount: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table>
|
<table>
|
||||||
<WoTableBody
|
<WoTableBody
|
||||||
tableData={tableData}
|
tableData={tableData}
|
||||||
renderRow={(row) => (
|
renderRow={(item) => (
|
||||||
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
<tr key={String(item.id)} data-testid={`row-${item.id}`}>
|
||||||
<td>{row.woNumber}</td>
|
<td>{item.woNumber}</td>
|
||||||
<td>
|
<td>
|
||||||
<AssignedToCell
|
<AssignedToCell
|
||||||
dispatcherId={row.dispatcherId}
|
dispatcherId={item.dispatcherId}
|
||||||
users={[DISPATCHER]}
|
users={[DISPATCHER]}
|
||||||
highlight={!row.dispatcherId}
|
highlight={!item.dispatcherId}
|
||||||
onSave={async (id, name) => {
|
onSave={async (id, name) => {
|
||||||
const ops = expandTablePatchToBoardFieldOps({
|
const ops = expandTablePatchToBoardFieldOps({
|
||||||
dispatcherId: id,
|
dispatcherId: id,
|
||||||
dispatcherName: name,
|
dispatcherName: name,
|
||||||
});
|
});
|
||||||
const patched = await patchBoardField(row.id, ops[0]!);
|
const patched = await patchBoardField(item.id, ops[0]!);
|
||||||
setRows((current) =>
|
setRow({ ...patched, dispatcherId: id, dispatcherName: name });
|
||||||
current.map((item) =>
|
|
||||||
String(item.id) === String(row.id)
|
|
||||||
? { ...patched, dispatcherId: id, dispatcherName: name }
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -179,25 +163,9 @@ function UnassignedAssignHarness({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("WoTableBody Unassigned queue (SH-165)", () => {
|
describe("WoTableBody weekly board (AAP day view)", () => {
|
||||||
it("renders Unassigned above the selected day without Unscheduled (SH-240)", () => {
|
it("renders the selected day and keeps dated-unassigned inline, with no Unassigned pin", () => {
|
||||||
const datedUnassigned = makeRow({ id: "u-dated", woNumber: "24991" });
|
const datedUnassigned = makeRow({ id: "u-dated", woNumber: "24991" });
|
||||||
const undatedUnassigned = makeRow({
|
|
||||||
id: "us1",
|
|
||||||
woNumber: "25021",
|
|
||||||
scheduledOn: "",
|
|
||||||
dayGroup: "",
|
|
||||||
dayLabel: "",
|
|
||||||
});
|
|
||||||
const undatedAssigned = makeRow({
|
|
||||||
id: "us-assigned",
|
|
||||||
woNumber: "25022",
|
|
||||||
dispatcherId: "sarah",
|
|
||||||
dispatcherName: "Sarah",
|
|
||||||
scheduledOn: "",
|
|
||||||
dayGroup: "",
|
|
||||||
dayLabel: "",
|
|
||||||
});
|
|
||||||
const datedAssigned = makeRow({
|
const datedAssigned = makeRow({
|
||||||
id: "day-assigned",
|
id: "day-assigned",
|
||||||
woNumber: "25023",
|
woNumber: "25023",
|
||||||
|
|
@ -205,142 +173,19 @@ describe("WoTableBody Unassigned queue (SH-165)", () => {
|
||||||
dispatcherName: "Sarah",
|
dispatcherName: "Sarah",
|
||||||
});
|
});
|
||||||
|
|
||||||
const tableData = baseTableData({
|
|
||||||
unassigned: [datedUnassigned, undatedUnassigned],
|
|
||||||
unscheduled: [undatedAssigned],
|
|
||||||
displayGroups: [
|
|
||||||
{
|
|
||||||
key: "06/02",
|
|
||||||
label: "TUE · 06/02",
|
|
||||||
date: "2026-06-02",
|
|
||||||
items: [datedAssigned],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<WoTableBody
|
|
||||||
tableData={tableData}
|
|
||||||
renderRow={(row) => (
|
|
||||||
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
|
||||||
<td>{row.woNumber}</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
renderInlineRow={() => null}
|
|
||||||
/>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const unassignedHeader = screen.getByRole("button", {
|
|
||||||
name: "Unassigned, 2 work orders, expanded",
|
|
||||||
});
|
|
||||||
const dayLabel = screen.getByText("TUE · 06/02");
|
|
||||||
|
|
||||||
expect(unassignedHeader.compareDocumentPosition(screen.getByTestId("row-u-dated"))).toBe(
|
|
||||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
||||||
);
|
|
||||||
expect(unassignedHeader.compareDocumentPosition(dayLabel)).toBe(
|
|
||||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByRole("button", { name: /Unscheduled/ })).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("row-u-dated")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("row-us1")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId("row-us-assigned")).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("row-day-assigned")).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByTestId(/row-/)).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("collapses and expands the Unassigned section via the disclosure control", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const setUnassignedOpen = vi.fn();
|
|
||||||
const tableData = baseTableData({
|
|
||||||
unassigned: [makeRow({ id: "u1", woNumber: "24991" })],
|
|
||||||
setUnassignedOpen,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<WoTableBody
|
|
||||||
tableData={tableData}
|
|
||||||
renderRow={(row) => (
|
|
||||||
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
|
||||||
<td>{row.woNumber}</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
renderInlineRow={() => null}
|
|
||||||
/>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Unassigned, 1 work order, expanded" }));
|
|
||||||
expect(setUnassignedOpen).toHaveBeenCalledWith(expect.any(Function));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders a scheduled unassigned WO from outside the selected week in the pin", () => {
|
|
||||||
const outOfWeek = makeRow({
|
|
||||||
id: "out-week",
|
|
||||||
woNumber: "26001",
|
|
||||||
scheduledOn: "2026-07-15",
|
|
||||||
dayGroup: "07/15",
|
|
||||||
dayLabel: "WED · 07/15",
|
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<WoTableBody
|
|
||||||
tableData={baseTableData({ unassigned: [outOfWeek] })}
|
|
||||||
renderRow={(row) => (
|
|
||||||
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
|
||||||
<td>{row.woNumber}</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
renderInlineRow={() => null}
|
|
||||||
/>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("row-out-week")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("WED · 07/15")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("distinguishes a still-loading queue from an empty one", () => {
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<WoTableBody
|
|
||||||
tableData={baseTableData({ unassignedLoading: true })}
|
|
||||||
renderRow={() => null}
|
|
||||||
renderInlineRow={() => null}
|
|
||||||
/>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/Loading unassigned work orders/)).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("No unassigned work orders.")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("announces an empty queue only once loading has settled", () => {
|
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<WoTableBody
|
|
||||||
tableData={baseTableData({ unassignedLoading: false })}
|
|
||||||
renderRow={() => null}
|
|
||||||
renderInlineRow={() => null}
|
|
||||||
/>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText(/Loading unassigned work orders/)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the Unassigned pin visible when the queue query fails", () => {
|
|
||||||
render(
|
render(
|
||||||
<table>
|
<table>
|
||||||
<WoTableBody
|
<WoTableBody
|
||||||
tableData={baseTableData({
|
tableData={baseTableData({
|
||||||
unassignedError: new Error("queue unavailable"),
|
displayGroups: [
|
||||||
|
{
|
||||||
|
key: "06/02",
|
||||||
|
label: "TUE · 06/02",
|
||||||
|
date: "2026-06-02",
|
||||||
|
items: [datedUnassigned, datedAssigned],
|
||||||
|
totalCount: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
})}
|
})}
|
||||||
renderRow={(row) => (
|
renderRow={(row) => (
|
||||||
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
||||||
|
|
@ -352,32 +197,15 @@ describe("WoTableBody Unassigned queue (SH-165)", () => {
|
||||||
</table>,
|
</table>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("queue unavailable")).toBeInTheDocument();
|
expect(screen.getByText("TUE · 06/02")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("2 WOs")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /Unassigned/ })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /Unscheduled/ })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("row-u-dated")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("row-day-assigned")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the Unassigned section visible with an empty state when the queue is empty", () => {
|
it("assigns a dated-unassigned row in the day group without a pin section", async () => {
|
||||||
render(
|
|
||||||
<table>
|
|
||||||
<WoTableBody
|
|
||||||
tableData={baseTableData()}
|
|
||||||
renderRow={(row) => (
|
|
||||||
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
|
||||||
<td>{row.woNumber}</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
renderInlineRow={() => null}
|
|
||||||
/>
|
|
||||||
</table>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", { name: "Unassigned, 0 work orders, expanded" }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId(/row-/)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes a pinned row immediately after assigning through the board PATCH path", async () => {
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const pinned = makeRow({
|
const pinned = makeRow({
|
||||||
id: "u-pin",
|
id: "u-pin",
|
||||||
|
|
@ -391,17 +219,14 @@ describe("WoTableBody Unassigned queue (SH-165)", () => {
|
||||||
return { ...pinned, dispatcherId: DISPATCHER.id, dispatcherName: DISPATCHER.name };
|
return { ...pinned, dispatcherId: DISPATCHER.id, dispatcherName: DISPATCHER.name };
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<UnassignedAssignHarness initialRow={pinned} patchBoardField={patchBoardField} />);
|
render(<DayAssignHarness initialRow={pinned} patchBoardField={patchBoardField} />);
|
||||||
|
|
||||||
expect(screen.getByTestId("row-u-pin")).toBeInTheDocument();
|
expect(screen.getByTestId("row-u-pin")).toBeInTheDocument();
|
||||||
await user.click(screen.getByRole("button", { name: /Select/ }));
|
await user.click(screen.getByRole("button", { name: /Select/ }));
|
||||||
await user.click(screen.getByRole("button", { name: /Sarah Chen/ }));
|
await user.click(screen.getByRole("button", { name: /Sarah Chen/ }));
|
||||||
|
|
||||||
expect(patchBoardField).toHaveBeenCalled();
|
expect(patchBoardField).toHaveBeenCalled();
|
||||||
expect(
|
expect(screen.queryByRole("button", { name: /Unassigned/ })).not.toBeInTheDocument();
|
||||||
screen.getByRole("button", { name: "Unassigned, 0 work orders, expanded" }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("row-u-pin")).toBeInTheDocument();
|
expect(screen.getByTestId("row-u-pin")).toBeInTheDocument();
|
||||||
expect(screen.getByText("TUE · 06/02")).toBeInTheDocument();
|
expect(screen.getByText("TUE · 06/02")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -336,4 +336,25 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
|
||||||
expect(screen.getByRole("button", { name: "All dispatchers" })).toBeEnabled();
|
expect(screen.getByRole("button", { name: "All dispatchers" })).toBeEnabled();
|
||||||
expect(screen.getByRole("button", { name: /work order types, all types/i })).toBeEnabled();
|
expect(screen.getByRole("button", { name: /work order types, all types/i })).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies Aveta required only through Advanced Filters", () => {
|
||||||
|
const view = renderWithProviders(<WorkOrdersListPage />, {
|
||||||
|
route: "/workorders",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
|
||||||
|
advOpen = true;
|
||||||
|
view.rerender(<WorkOrdersListPage />);
|
||||||
|
|
||||||
|
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: /Aveta Compliance/i }));
|
||||||
|
fireEvent.click(within(sheet).getByRole("checkbox", { name: /Aveta required only/i }));
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i }));
|
||||||
|
|
||||||
|
const appliedCalls = applyAdvancedFilters.mock.calls;
|
||||||
|
const applied = appliedCalls[appliedCalls.length - 1]?.[0] as typeof EMPTY_ADVANCED_FILTERS;
|
||||||
|
expect(applied.avetaOnly).toBe(true);
|
||||||
|
expect(toBoardSearchQueryParams({ filters: applied }).avetaOnly).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ describe("Work Orders dispatcher filter layout (SH-242)", () => {
|
||||||
);
|
);
|
||||||
expect(weekAndDay.contains(dispatcher)).toBe(false);
|
expect(weekAndDay.contains(dispatcher)).toBe(false);
|
||||||
expect(weekAndDay.contains(week)).toBe(true);
|
expect(weekAndDay.contains(week)).toBe(true);
|
||||||
expect(screen.getByText("0 of 0")).toBeInTheDocument();
|
expect(weekAndDay.contains(screen.getByTestId("wo-type-filter-trigger"))).toBe(true);
|
||||||
|
expect(weekAndDay.contains(screen.getByText("0 of 0"))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,43 +10,39 @@ vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { fetchUnassignedBoardRows } from "@/domain/work-orders/api/fetch-unassigned-board-rows";
|
import { fetchUnassignedBoardPage } from "@/domain/work-orders/api/fetch-unassigned-board-rows";
|
||||||
|
|
||||||
function pageResult(items: Pick<WorkOrderTableRow, "id">[], totalCount: number) {
|
function pageResult(items: Pick<WorkOrderTableRow, "id">[], totalCount: number, page = 0) {
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
scheduled: [],
|
scheduled: [],
|
||||||
unscheduled: [],
|
unscheduled: [],
|
||||||
totalCount,
|
totalCount,
|
||||||
returnedCount: items.length,
|
returnedCount: items.length,
|
||||||
page: 0,
|
page,
|
||||||
pageSize: BOARD_SEARCH_MAX_PAGE_SIZE,
|
pageSize: BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("fetchUnassignedBoardRows", () => {
|
describe("fetchUnassignedBoardPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
searchBoard.mockReset();
|
searchBoard.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pages until totalCount is exhausted", async () => {
|
it("fetches a single search page instead of exhausting totalCount", async () => {
|
||||||
const first = Array.from({ length: BOARD_SEARCH_MAX_PAGE_SIZE }, (_, index) => ({
|
const first = Array.from({ length: 50 }, (_, index) => ({ id: `a-${index}` }));
|
||||||
id: `a-${index}`,
|
searchBoard.mockResolvedValueOnce(pageResult(first, 1778, 0));
|
||||||
}));
|
|
||||||
const second = [{ id: "b-0" }];
|
|
||||||
searchBoard
|
|
||||||
.mockResolvedValueOnce(pageResult(first, 101))
|
|
||||||
.mockResolvedValueOnce(pageResult(second, 101));
|
|
||||||
|
|
||||||
const rows = await fetchUnassignedBoardRows();
|
const result = await fetchUnassignedBoardPage(0, 50);
|
||||||
|
|
||||||
expect(searchBoard).toHaveBeenCalledTimes(2);
|
expect(searchBoard).toHaveBeenCalledTimes(1);
|
||||||
expect(searchBoard.mock.calls[0]?.[0]).toMatchObject({
|
expect(searchBoard.mock.calls[0]?.[0]).toMatchObject({
|
||||||
page: 0,
|
page: 0,
|
||||||
|
pageSize: 50,
|
||||||
dispatchers: ["__unassigned__"],
|
dispatchers: ["__unassigned__"],
|
||||||
datePreset: "Custom",
|
datePreset: "Custom",
|
||||||
});
|
});
|
||||||
expect(searchBoard.mock.calls[1]?.[0]).toMatchObject({ page: 1 });
|
expect(result.items).toHaveLength(50);
|
||||||
expect(rows).toHaveLength(101);
|
expect(result.totalCount).toBe(1778);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,7 @@ describe("workOrdersApi.getBoard", () => {
|
||||||
searchParams: expect.any(URLSearchParams),
|
searchParams: expect.any(URLSearchParams),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
expect(result.returnedCount).toBe(1);
|
expect(result.returnedCount).toBe(1);
|
||||||
expect(result.totalCount).toBe(2);
|
expect(result.totalCount).toBe(2);
|
||||||
expect(result.unscheduled).toHaveLength(1);
|
expect(result.unscheduled).toHaveLength(1);
|
||||||
|
|
@ -253,6 +254,30 @@ describe("workOrdersApi.getBoard", () => {
|
||||||
expect(result.scheduled[0]?.apptTime).toBe("07:00 – 09:00");
|
expect(result.scheduled[0]?.apptTime).toBe("07:00 – 09:00");
|
||||||
expect(result.scheduled[0]?.type).toBe("Reactive");
|
expect(result.scheduled[0]?.type).toBe("Reactive");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not dump unscheduled rows into items when counts are zero", async () => {
|
||||||
|
apiGet.mockResolvedValue({
|
||||||
|
weekStart: "2026-08-24",
|
||||||
|
weekEnd: "2026-08-28",
|
||||||
|
counts: { returned: 0, total: 0 },
|
||||||
|
unscheduled: Array.from({ length: 5 }, (_, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
woNumber: `0000000000${index + 1}`,
|
||||||
|
lifecycleStatusLabel: "Incomplete",
|
||||||
|
})),
|
||||||
|
scheduled: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await workOrdersApi.getBoard({
|
||||||
|
weekStart: "2026-08-24",
|
||||||
|
weekEnd: "2026-08-28",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.items).toEqual([]);
|
||||||
|
expect(result.unscheduled).toHaveLength(5);
|
||||||
|
expect(result.totalCount).toBe(0);
|
||||||
|
expect(result.returnedCount).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("workOrdersApi.searchBoard", () => {
|
describe("workOrdersApi.searchBoard", () => {
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,28 @@ describe("mapWorkOrderBoardDetail", () => {
|
||||||
expect(files[1]?.fileType).toBe("pdf");
|
expect(files[1]?.fileType).toBe("pdf");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps Aveta board category to Extra Docs Aveta", () => {
|
||||||
|
const aveta = mapBoardDetailMediaItem({
|
||||||
|
id: 22,
|
||||||
|
category: 5,
|
||||||
|
url: "https://cdn.example.com/aveta.pdf",
|
||||||
|
fileName: "aveta.pdf",
|
||||||
|
});
|
||||||
|
const fromName = mapBoardDetailMediaItem({
|
||||||
|
id: 23,
|
||||||
|
category: "Aveta",
|
||||||
|
url: "https://cdn.example.com/aveta.pdf",
|
||||||
|
fileName: "aveta.pdf",
|
||||||
|
});
|
||||||
|
const files = boardMediaItemsToMediaFiles([aveta, fromName]);
|
||||||
|
expect(files[0]).toMatchObject({
|
||||||
|
category: "Aveta",
|
||||||
|
purpose: "Aveta",
|
||||||
|
fileType: "pdf",
|
||||||
|
});
|
||||||
|
expect(files[1]?.purpose).toBe("Aveta");
|
||||||
|
});
|
||||||
|
|
||||||
it("maps After and Completion board categories to purpose for completion UI", () => {
|
it("maps After and Completion board categories to purpose for completion UI", () => {
|
||||||
const after = mapBoardDetailMediaItem({
|
const after = mapBoardDetailMediaItem({
|
||||||
id: 20,
|
id: 20,
|
||||||
|
|
|
||||||
|
|
@ -404,6 +404,16 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
||||||
expect(payload.trade).toBeUndefined();
|
expect(payload.trade).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends avetaRequired on create when the Service & Notes flag is set", () => {
|
||||||
|
const payload = mapWizardDraftToBoardCreatePayload({
|
||||||
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
type: "PM",
|
||||||
|
siteCode: "BK5",
|
||||||
|
avetaRequired: true,
|
||||||
|
});
|
||||||
|
expect(payload.avetaRequired).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("maps numeric WO numbers", () => {
|
it("maps numeric WO numbers", () => {
|
||||||
const payload = mapWizardDraftToBoardCreatePayload({
|
const payload = mapWizardDraftToBoardCreatePayload({
|
||||||
...EMPTY_WIZARD_DRAFT,
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
|
|
||||||
|
|
@ -78,11 +78,13 @@ describe("expandTablePatchToBoardFieldOps", () => {
|
||||||
pm: "Backflow Preventers",
|
pm: "Backflow Preventers",
|
||||||
extraServices: ["Filter change", "Coil clean"],
|
extraServices: ["Filter change", "Coil clean"],
|
||||||
serviceNotes: "service notes",
|
serviceNotes: "service notes",
|
||||||
|
avetaRequired: true,
|
||||||
}),
|
}),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{ field: "pm", value: "Backflow Preventers" },
|
{ field: "pm", value: "Backflow Preventers" },
|
||||||
{ field: "extraServices", value: '["Filter change","Coil clean"]' },
|
{ field: "extraServices", value: '["Filter change","Coil clean"]' },
|
||||||
{ field: "serviceNotes", value: "service notes" },
|
{ field: "serviceNotes", value: "service notes" },
|
||||||
|
{ field: "avetaRequired", value: "true" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,44 @@ describe("mapWorkOrderTableRow isAddOn", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("mapWorkOrderTableRow avetaRequired", () => {
|
||||||
|
it("maps avetaRequired and originalDate from the same board list row", () => {
|
||||||
|
const row = mapWorkOrderTableRow({
|
||||||
|
id: 8,
|
||||||
|
workOrderType: WORK_ORDER_TYPE_API.PM,
|
||||||
|
avetaRequired: true,
|
||||||
|
originalDate: "2026-05-08",
|
||||||
|
});
|
||||||
|
expect(row.avetaRequired).toBe(true);
|
||||||
|
expect(row.originalDate).toBe("2026-05-08");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults avetaRequired to false when omitted", () => {
|
||||||
|
const row = mapWorkOrderTableRow({
|
||||||
|
id: 8,
|
||||||
|
workOrderType: WORK_ORDER_TYPE_API.PM,
|
||||||
|
});
|
||||||
|
expect(row.avetaRequired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps hasAvetaDocument from the board list row", () => {
|
||||||
|
const attached = mapWorkOrderTableRow({
|
||||||
|
id: 9,
|
||||||
|
workOrderType: WORK_ORDER_TYPE_API.PM,
|
||||||
|
avetaRequired: true,
|
||||||
|
hasAvetaDocument: true,
|
||||||
|
});
|
||||||
|
expect(attached.hasAvetaDocument).toBe(true);
|
||||||
|
|
||||||
|
const pending = mapWorkOrderTableRow({
|
||||||
|
id: 10,
|
||||||
|
workOrderType: WORK_ORDER_TYPE_API.PM,
|
||||||
|
avetaRequired: true,
|
||||||
|
});
|
||||||
|
expect(pending.hasAvetaDocument).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("mapWorkOrderTableRow primaryDispatchStatus", () => {
|
describe("mapWorkOrderTableRow primaryDispatchStatus", () => {
|
||||||
it.each([
|
it.each([
|
||||||
["primaryDispatchStatus", "Refused"],
|
["primaryDispatchStatus", "Refused"],
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,18 @@ describe("clearAdvancedFilterGroup", () => {
|
||||||
expect(chips.some((c) => c.key === "doc" && c.group === "Comp doc")).toBe(true);
|
expect(chips.some((c) => c.key === "doc" && c.group === "Comp doc")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds an Aveta required chip", () => {
|
||||||
|
const filters = {
|
||||||
|
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
|
||||||
|
avetaOnly: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const chips = buildAdvancedFilterChips(filters);
|
||||||
|
expect(
|
||||||
|
chips.some((c) => c.key === "aveta" && c.group === "Aveta" && c.value === "Required"),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("clears uplift chip and drops hasUplift with statuses", () => {
|
it("clears uplift chip and drops hasUplift with statuses", () => {
|
||||||
const filters = {
|
const filters = {
|
||||||
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
|
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ describe("toBoardQueryParams", () => {
|
||||||
expect(params.overdue).toBe(true);
|
expect(params.overdue).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits types when filter is All", () => {
|
it("omits types when the set is empty", () => {
|
||||||
const params = toBoardQueryParams(baseBar, "");
|
const params = toBoardQueryParams(baseBar, "");
|
||||||
expect(params.types).toBeUndefined();
|
expect(params.types).toBeUndefined();
|
||||||
expect(params.overdue).toBeUndefined();
|
expect(params.overdue).toBeUndefined();
|
||||||
|
|
@ -90,6 +90,16 @@ describe("toBoardSearchQueryParams", () => {
|
||||||
expect(params.dateTo).toBe("2099-12-31");
|
expect(params.dateTo).toBe("2099-12-31");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards chrome pageSize 50 instead of the search max", () => {
|
||||||
|
const params = toBoardSearchQueryParams({
|
||||||
|
filters: { ...EMPTY_ADVANCED_FILTERS },
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
});
|
||||||
|
expect(params.page).toBe(1);
|
||||||
|
expect(params.pageSize).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
it("maps date presets to PascalCase API values", () => {
|
it("maps date presets to PascalCase API values", () => {
|
||||||
const params = toBoardSearchQueryParams({
|
const params = toBoardSearchQueryParams({
|
||||||
filters: { ...EMPTY_ADVANCED_FILTERS, dateRange: "last-3-months" },
|
filters: { ...EMPTY_ADVANCED_FILTERS, dateRange: "last-3-months" },
|
||||||
|
|
@ -218,6 +228,11 @@ describe("toBoardSearchQueryParams", () => {
|
||||||
expect(addOn.addOn).toBe(true);
|
expect(addOn.addOn).toBe(true);
|
||||||
expect(addOn.carriedOver).toBeUndefined();
|
expect(addOn.carriedOver).toBeUndefined();
|
||||||
|
|
||||||
|
const aveta = toBoardSearchQueryParams({
|
||||||
|
filters: { ...EMPTY_ADVANCED_FILTERS, avetaOnly: true },
|
||||||
|
});
|
||||||
|
expect(aveta.avetaOnly).toBe(true);
|
||||||
|
|
||||||
const service = toBoardSearchQueryParams({
|
const service = toBoardSearchQueryParams({
|
||||||
filters: { ...EMPTY_ADVANCED_FILTERS, pmTypes: new Set(["HVAC"]) },
|
filters: { ...EMPTY_ADVANCED_FILTERS, pmTypes: new Set(["HVAC"]) },
|
||||||
});
|
});
|
||||||
|
|
@ -297,7 +312,7 @@ describe("toBoardSearchQueryParams", () => {
|
||||||
filters: { ...EMPTY_ADVANCED_FILTERS },
|
filters: { ...EMPTY_ADVANCED_FILTERS },
|
||||||
pageSize: 500,
|
pageSize: 500,
|
||||||
});
|
});
|
||||||
expect(params.pageSize).toBe(100);
|
expect(params.pageSize).toBe(200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,23 @@ import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
DAY_PAGE_SIZE_OPTIONS,
|
DAY_PAGE_SIZE_OPTIONS,
|
||||||
DEFAULT_DAY_PAGE_SIZE,
|
DEFAULT_DAY_PAGE_SIZE,
|
||||||
|
asDayPageSize,
|
||||||
findItemPageIndex,
|
findItemPageIndex,
|
||||||
formatDayPaginationSummary,
|
formatDayPaginationSummary,
|
||||||
|
nextSearchPageIfOutOfRange,
|
||||||
pageIndexForItem,
|
pageIndexForItem,
|
||||||
paginateItems,
|
paginateItems,
|
||||||
|
pickBoardPager,
|
||||||
shouldShowDayPagination,
|
shouldShowDayPagination,
|
||||||
|
shouldShowSearchPagination,
|
||||||
} from "@/domain/work-orders/utils/day-pagination";
|
} from "@/domain/work-orders/utils/day-pagination";
|
||||||
|
|
||||||
describe("day pagination utils (SH-241)", () => {
|
describe("day pagination utils (SH-241)", () => {
|
||||||
it("defaults to 50 and exposes 25/50/100/200", () => {
|
it("defaults to 50 and exposes 25/50/100/200", () => {
|
||||||
expect(DEFAULT_DAY_PAGE_SIZE).toBe(50);
|
expect(DEFAULT_DAY_PAGE_SIZE).toBe(50);
|
||||||
expect(DAY_PAGE_SIZE_OPTIONS).toEqual([25, 50, 100, 200]);
|
expect(DAY_PAGE_SIZE_OPTIONS).toEqual([25, 50, 100, 200]);
|
||||||
|
expect(asDayPageSize(200)).toBe(200);
|
||||||
|
expect(asDayPageSize(13)).toBe(DEFAULT_DAY_PAGE_SIZE);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("paginates 60 items at 50 per page", () => {
|
it("paginates 60 items at 50 per page", () => {
|
||||||
|
|
@ -70,6 +76,56 @@ describe("day pagination utils (SH-241)", () => {
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows search pagination when advanced filters return rows", () => {
|
||||||
|
expect(shouldShowSearchPagination({ isLoading: false, hasResults: true })).toBe(true);
|
||||||
|
expect(shouldShowSearchPagination({ isLoading: true, hasResults: true })).toBe(false);
|
||||||
|
expect(shouldShowSearchPagination({ isLoading: false, hasResults: false })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows search pagination when the current page is empty but totalCount is positive", () => {
|
||||||
|
const pager = pickBoardPager({
|
||||||
|
advActive: true,
|
||||||
|
isLoading: false,
|
||||||
|
noSearchResults: false,
|
||||||
|
advResultCount: 0,
|
||||||
|
search: { page: 99, pageSize: 50, totalCount: 1778 },
|
||||||
|
day: {
|
||||||
|
pageSize: 50,
|
||||||
|
pageIndex: 0,
|
||||||
|
pageCount: 1,
|
||||||
|
itemCount: 0,
|
||||||
|
footerLabel: "Mon · 08/17",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(pager.show).toBe(true);
|
||||||
|
expect(pager.itemCount).toBe(1778);
|
||||||
|
expect(pager.pageCount).toBe(36);
|
||||||
|
expect(pager.pageIndex).toBe(35);
|
||||||
|
expect(pager.footerLabel).toBe("Results");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pages a 1778-row search at 50 per page without dumping the universe", () => {
|
||||||
|
const pager = pickBoardPager({
|
||||||
|
advActive: true,
|
||||||
|
isLoading: false,
|
||||||
|
noSearchResults: false,
|
||||||
|
advResultCount: 50,
|
||||||
|
search: { page: 0, pageSize: 50, totalCount: 1778 },
|
||||||
|
day: {
|
||||||
|
pageSize: 50,
|
||||||
|
pageIndex: 0,
|
||||||
|
pageCount: 1,
|
||||||
|
itemCount: 0,
|
||||||
|
footerLabel: "Mon · 08/17",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(pager.show).toBe(true);
|
||||||
|
expect(pager.itemCount).toBe(1778);
|
||||||
|
expect(pager.pageCount).toBe(36);
|
||||||
|
expect(pager.pageIndex).toBe(0);
|
||||||
|
expect(pager.footerLabel).toBe("Results");
|
||||||
|
});
|
||||||
|
|
||||||
it("maps a row past the first page onto page 2 at the default size", () => {
|
it("maps a row past the first page onto page 2 at the default size", () => {
|
||||||
const items = Array.from({ length: 60 }, (_, index) => ({ id: `wo-${index}` }));
|
const items = Array.from({ length: 60 }, (_, index) => ({ id: `wo-${index}` }));
|
||||||
expect(pageIndexForItem(50, 50)).toBe(1);
|
expect(pageIndexForItem(50, 50)).toBe(1);
|
||||||
|
|
@ -82,3 +138,21 @@ describe("day pagination utils (SH-241)", () => {
|
||||||
expect(formatDayPaginationSummary("Tue · 06/02", 1)).toBe("TUE · 06/02 · 1 item");
|
expect(formatDayPaginationSummary("Tue · 06/02", 1)).toBe("TUE · 06/02 · 1 item");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("nextSearchPageIfOutOfRange", () => {
|
||||||
|
it("returns the last valid page when a search shrinks to one page", () => {
|
||||||
|
expect(nextSearchPageIfOutOfRange({ page: 1, pageSize: 50, totalCount: 40 })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a still-valid second page alone", () => {
|
||||||
|
expect(nextSearchPageIfOutOfRange({ page: 1, pageSize: 50, totalCount: 60 })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not pull an empty result set onto page 1", () => {
|
||||||
|
expect(nextSearchPageIfOutOfRange({ page: 1, pageSize: 50, totalCount: 0 })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the last valid page alone", () => {
|
||||||
|
expect(nextSearchPageIfOutOfRange({ page: 0, pageSize: 50, totalCount: 40 })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
51
src/test/domain/work-orders/utils/extra-docs-aveta.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
import {
|
||||||
|
hasExplicitAvetaFile,
|
||||||
|
isAvetaDocumentAttached,
|
||||||
|
resolveExtraDocCategory,
|
||||||
|
} from "@/domain/work-orders/utils/extra-docs-aveta";
|
||||||
|
|
||||||
|
function file(category: MediaFile["category"]): MediaFile {
|
||||||
|
return {
|
||||||
|
id: "1",
|
||||||
|
name: "doc.pdf",
|
||||||
|
fileType: "pdf",
|
||||||
|
url: "blob:1",
|
||||||
|
category,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("extra-docs-aveta", () => {
|
||||||
|
it("does not label the first extra file as Aveta when the WO is flagged and none is Aveta", () => {
|
||||||
|
const files = [file("Photo"), file("Report")];
|
||||||
|
expect(resolveExtraDocCategory(files[0])).toBe("Photo");
|
||||||
|
expect(resolveExtraDocCategory(files[1])).toBe("Report");
|
||||||
|
expect(hasExplicitAvetaFile(files)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an explicit Aveta file and does not relabel others", () => {
|
||||||
|
const files = [file("Photo"), file("Aveta")];
|
||||||
|
expect(resolveExtraDocCategory(files[0])).toBe("Photo");
|
||||||
|
expect(resolveExtraDocCategory(files[1])).toBe("Aveta");
|
||||||
|
expect(hasExplicitAvetaFile(files)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not label extras as Aveta when the WO is not flagged", () => {
|
||||||
|
const files = [file("Photo")];
|
||||||
|
expect(resolveExtraDocCategory(files[0])).toBe("Photo");
|
||||||
|
expect(hasExplicitAvetaFile(files)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats purpose Aveta as an explicit Aveta file", () => {
|
||||||
|
const aveta: MediaFile = { ...file("Other"), purpose: "Aveta" };
|
||||||
|
expect(resolveExtraDocCategory(aveta)).toBe("Aveta");
|
||||||
|
expect(hasExplicitAvetaFile([aveta])).toBe(true);
|
||||||
|
expect(isAvetaDocumentAttached(false, [aveta])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses hasAvetaDocument from the board row after reopen", () => {
|
||||||
|
expect(isAvetaDocumentAttached(true, [])).toBe(true);
|
||||||
|
expect(isAvetaDocumentAttached(false, [file("Other")])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -65,7 +65,7 @@ describe("formatBoardAuditAction", () => {
|
||||||
oldValue: "false",
|
oldValue: "false",
|
||||||
newValue: "true",
|
newValue: "true",
|
||||||
}),
|
}),
|
||||||
).toBe("WO marked as Past Due. Due date has passed without completion.");
|
).toBe("WO marked as Past Due. Schedule On has passed without completion.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to from → to for unknown fields", () => {
|
it("falls back to from → to for unknown fields", () => {
|
||||||
|
|
|
||||||