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`.
|
||||
|
||||
## 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
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
| ---------------- | --------------------- | ------------------------------------------- |
|
||||
| `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` |
|
||||
| `sortDir` | `sortDir` | `asc` \| `desc` |
|
||||
| `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).
|
||||
- `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.
|
||||
|
||||
### `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();
|
||||
});
|
||||
|
||||
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 ({
|
||||
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) {
|
||||
const wednesday = Array.from({ length: 60 }, (_, index) =>
|
||||
boardRow(1000 + index, `WO-${1000 + index}`, "2026-08-19"),
|
||||
|
|
@ -43,7 +55,15 @@ async function mockWorkOrderBoard(page: Page) {
|
|||
async (route) => {
|
||||
const url = route.request().url();
|
||||
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;
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
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,
|
||||
}) => {
|
||||
const pager = page.getByTestId("wo-day-pagination");
|
||||
await page.goto("/workorders");
|
||||
await hideQueryDevtools(page);
|
||||
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
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-1050")).toHaveCount(0);
|
||||
|
||||
await page.getByLabel("Rows per page").selectOption("25");
|
||||
await expect(page.getByText("Page 1 of 3")).toBeVisible();
|
||||
await pager.getByLabel("Rows per page").selectOption("25");
|
||||
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 page.getByTestId("wo-day-pagination-next").evaluate((node) => {
|
||||
(node as HTMLButtonElement).click();
|
||||
});
|
||||
await expect(page.getByText("Page 2 of 3")).toBeVisible();
|
||||
await hideQueryDevtools(page);
|
||||
await page.getByTestId("wo-day-pagination-next").click();
|
||||
await expect(pager.getByText("Page 2 of 3")).toBeVisible();
|
||||
await expect(page.getByText("WO-1025").first()).toBeVisible();
|
||||
await expect(page.getByText("WO-1000")).toHaveCount(0);
|
||||
|
||||
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 page.getByRole("button", { name: "Week 8/17–8/21" }).click({ force: true });
|
||||
await page.getByRole("button", { name: /Aug 10/ }).click({ force: true });
|
||||
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: /Indicators/i }).click();
|
||||
await page.getByRole("button", { name: "Rescheduled ≥ 2" }).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 expect(page.getByTestId("wo-day-pagination")).toBeVisible();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export function readTotalCount(record: Record<string, unknown>, fallback: number
|
|||
return (
|
||||
(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) ??
|
||||
fallback
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { CircularProgress } from "@mui/material";
|
||||
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 { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
|
@ -34,6 +36,10 @@ export function SlideOverHeader({
|
|||
>
|
||||
#{row.woNumber || "—"}
|
||||
</Text>
|
||||
<RescheduleCounterPill count={row.rescheduleCount} originalDate={row.originalDate} />
|
||||
{Boolean(row.avetaRequired) && (
|
||||
<AvetaBadge size={13} attached={Boolean(row.hasAvetaDocument)} />
|
||||
)}
|
||||
|
||||
{isCancelled && (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -5,12 +5,18 @@ import {
|
|||
Film,
|
||||
Image as ImageIcon,
|
||||
Paperclip,
|
||||
ShieldCheck,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
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 {
|
||||
hasExplicitAvetaFile,
|
||||
resolveExtraDocCategory,
|
||||
} from "@/domain/work-orders/utils/extra-docs-aveta";
|
||||
import {
|
||||
canDeleteWorkOrderMedia,
|
||||
formatMediaSizeLimit,
|
||||
|
|
@ -27,22 +33,113 @@ const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
|||
other: Paperclip,
|
||||
};
|
||||
|
||||
const EXTRA_DOC_PURPOSES: Array<Extract<UploadableMediaCategory, "Extra" | "Aveta">> = [
|
||||
"Extra",
|
||||
"Aveta",
|
||||
];
|
||||
|
||||
type SlideOverExtrasTabProps = {
|
||||
uploads: MediaFile[];
|
||||
isReadOnly: boolean;
|
||||
pending?: boolean;
|
||||
avetaRequired?: boolean;
|
||||
onUpload: (files: File[]) => 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({
|
||||
uploads,
|
||||
isReadOnly,
|
||||
pending = false,
|
||||
avetaRequired = false,
|
||||
onUpload,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
}: SlideOverExtrasTabProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const avetaAttached = hasExplicitAvetaFile(uploads);
|
||||
|
||||
const addFiles = (files: FileList | null) => {
|
||||
if (isReadOnly || pending || !files || files.length === 0) return;
|
||||
|
|
@ -70,6 +167,19 @@ export function SlideOverExtrasTab({
|
|||
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
|
||||
Extra Documents
|
||||
</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>
|
||||
|
||||
{!isReadOnly && (
|
||||
|
|
@ -122,40 +232,16 @@ export function SlideOverExtrasTab({
|
|||
</Text>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{uploads.map((file) => {
|
||||
const ItemIcon = FILETYPE_ICON[file.fileType];
|
||||
const showDelete = !isReadOnly && canDeleteWorkOrderMedia(file);
|
||||
return (
|
||||
<div key={file.id} className="flex items-center gap-2 py-1">
|
||||
<ItemIcon
|
||||
size={12}
|
||||
className="shrink-0"
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{uploads.map((file) => (
|
||||
<ExtraDocRow
|
||||
key={file.id}
|
||||
file={file}
|
||||
isReadOnly={isReadOnly}
|
||||
pending={pending}
|
||||
onRemove={onRemove}
|
||||
onCategorize={onCategorize}
|
||||
/>
|
||||
))}
|
||||
</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 { VendorCell } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-cell";
|
||||
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 { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
|
||||
import type {
|
||||
|
|
@ -100,6 +101,8 @@ export function SlideOverInfoTabDisplayView({
|
|||
pmTypes={pmTypes}
|
||||
serviceNotes={source.serviceNotes}
|
||||
extraServices={source.extraServices}
|
||||
avetaRequired={source.avetaRequired}
|
||||
avetaAttached={source.hasAvetaDocument}
|
||||
woNumber={source.woNumber}
|
||||
viewOnly
|
||||
onSave={() => undefined}
|
||||
|
|
@ -108,6 +111,9 @@ export function SlideOverInfoTabDisplayView({
|
|||
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
|
||||
<PmTypeIcon pmType={source.pm} size={14} />
|
||||
{source.pm}
|
||||
{Boolean(source.avetaRequired) && (
|
||||
<AvetaBadge size={11} attached={Boolean(source.hasAvetaDocument)} />
|
||||
)}
|
||||
</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 { 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 { 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 { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
|
||||
import { SiteCell } from "@/app/(protected)/workorders/_components/list/table/cells/site-cell";
|
||||
|
|
@ -48,6 +49,7 @@ export function SlideOverInfoTabEditView({
|
|||
value={draft.woNumber}
|
||||
rescheduleCount={draft.rescheduleCount}
|
||||
carriedOver={draft.carriedOver}
|
||||
originalDate={draft.originalDate}
|
||||
currentId={draft.id}
|
||||
allOrders={allOrders}
|
||||
onSave={(woNumber) => onDraftChange({ woNumber })}
|
||||
|
|
@ -113,7 +115,11 @@ export function SlideOverInfoTabEditView({
|
|||
/>
|
||||
</SlideOverInfoRow>
|
||||
<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 label="Appt Time">
|
||||
<ApptTimeCell
|
||||
|
|
@ -127,12 +133,15 @@ export function SlideOverInfoTabEditView({
|
|||
pmTypes={pmTypes}
|
||||
serviceNotes={draft.serviceNotes}
|
||||
extraServices={draft.extraServices}
|
||||
avetaRequired={draft.avetaRequired}
|
||||
woNumber={draft.woNumber}
|
||||
onSave={(pm) => onDraftChange({ pm })}
|
||||
onSaveDetails={(details) =>
|
||||
onDraftChange({
|
||||
pm: details.pm,
|
||||
serviceNotes: details.serviceNotes,
|
||||
extraServices: details.extraServices,
|
||||
avetaRequired: details.avetaRequired,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -44,7 +44,9 @@ export function useSlideOverMediaState({
|
|||
const extrasUploads = useMemo(
|
||||
() =>
|
||||
boardMediaItemsToMediaFiles(
|
||||
(detailForRow?.media ?? []).filter((item) => item.category === "Extra"),
|
||||
(detailForRow?.media ?? []).filter(
|
||||
(item) => item.category === "Extra" || item.category === "Aveta",
|
||||
),
|
||||
),
|
||||
[detailForRow?.media],
|
||||
);
|
||||
|
|
@ -65,6 +67,7 @@ export function useSlideOverMediaState({
|
|||
setMediaBatchPending(true);
|
||||
try {
|
||||
for (const file of files) {
|
||||
// Upload stays Extra; Aveta is applied only by an explicit Extra Docs PATCH.
|
||||
await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
||||
}
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
WorkOrderUser,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
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 { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel";
|
||||
|
||||
|
|
@ -146,12 +147,18 @@ export function WorkOrderSlideOverTabs({
|
|||
>
|
||||
<SlideOverExtrasTab
|
||||
uploads={state.extrasUploads}
|
||||
avetaRequired={resolveSlideOverAvetaRequired(
|
||||
state.editing,
|
||||
infoSource?.avetaRequired,
|
||||
activeDraft?.avetaRequired,
|
||||
)}
|
||||
isReadOnly={state.isExtrasReadOnly}
|
||||
pending={state.mediaPending}
|
||||
onUpload={(files) => {
|
||||
void state.uploadExtraFiles(files);
|
||||
}}
|
||||
onRemove={state.removeMedia}
|
||||
onCategorize={state.categorizeMedia}
|
||||
/>
|
||||
</WorkOrderSlideOverTabPanel>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ import {
|
|||
import { AdvDocPill, AdvStatusPill } from "./adv-filter-pill";
|
||||
import { AdvDateRangeSection } from "./adv-date-range-section";
|
||||
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 { AdvSeverityList } from "./adv-severity-list";
|
||||
import { AdvTypeGrid } from "./adv-type-grid";
|
||||
|
|
@ -127,6 +131,8 @@ export function AdvancedSearchFilterSections({
|
|||
|
||||
<AdvIndicatorsSection draft={draft} setDraft={setDraft} />
|
||||
|
||||
<AdvAvetaSection draft={draft} setDraft={setDraft} />
|
||||
|
||||
<AdvSection icon={Flame} label="Service" count={draft.pmTypes.size}>
|
||||
<AdvMultiSelect
|
||||
options={pmTypes.map((pmType) => ({ value: pmType, label: pmType }))}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
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 { AdvDocPill } from "./adv-filter-pill";
|
||||
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({
|
||||
draft,
|
||||
setDraft,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export function buildInlineDraftCreateInput(
|
|||
dueDate: draft.dueDate,
|
||||
pm: draft.pm,
|
||||
extraServices: draft.extraServices ?? [],
|
||||
avetaRequired: Boolean(draft.avetaRequired),
|
||||
pmNote: draft.serviceNotes ?? "",
|
||||
vendorId: draft.vendorId || "",
|
||||
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
|
||||
}
|
||||
aria-label="Due Date"
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<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 { EditableWONumberInput } from "./editable-wo-number-input";
|
||||
import { HighlightText } from "./highlight-text";
|
||||
import { AddOnPill, CarriedOverCounterPill, RescheduleCounterPill } from "./wo-counter-pills";
|
||||
|
||||
type EditableWONumberCellProps = {
|
||||
value: string;
|
||||
rescheduleCount: number;
|
||||
carriedOver?: number;
|
||||
isAddOn?: boolean;
|
||||
originalDate?: string;
|
||||
q?: string;
|
||||
currentId: string | number;
|
||||
allOrders: WorkOrderTableRow[];
|
||||
|
|
@ -19,42 +21,6 @@ type EditableWONumberCellProps = {
|
|||
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(
|
||||
draft: string,
|
||||
allOrders: WorkOrderTableRow[],
|
||||
|
|
@ -70,6 +36,7 @@ export function EditableWONumberCell({
|
|||
rescheduleCount,
|
||||
carriedOver = 0,
|
||||
isAddOn = false,
|
||||
originalDate,
|
||||
q,
|
||||
currentId,
|
||||
allOrders,
|
||||
|
|
@ -150,8 +117,8 @@ export function EditableWONumberCell({
|
|||
{value ? <HighlightText text={value} q={q} /> : "-"}
|
||||
</span>
|
||||
<AddOnPill when={isAddOn} />
|
||||
<CounterPill label="Reschedules" count={rescheduleCount} />
|
||||
<CounterPill label="Carried over" count={carriedOver} />
|
||||
<RescheduleCounterPill count={rescheduleCount} originalDate={originalDate} />
|
||||
<CarriedOverCounterPill count={carriedOver} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -187,8 +154,8 @@ export function EditableWONumberCell({
|
|||
</span>
|
||||
)}
|
||||
<AddOnPill when={isAddOn} />
|
||||
<CounterPill label="Reschedules" count={rescheduleCount} />
|
||||
<CounterPill label="Carried over" count={carriedOver} />
|
||||
<RescheduleCounterPill count={rescheduleCount} originalDate={originalDate} />
|
||||
<CarriedOverCounterPill count={carriedOver} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
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 { 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 { PmTypePickerPopover } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover";
|
||||
import {
|
||||
|
|
@ -20,6 +21,8 @@ type PMTypeCellProps = {
|
|||
onSave: (v: string) => void;
|
||||
serviceNotes?: string;
|
||||
extraServices?: string[];
|
||||
avetaRequired?: boolean;
|
||||
avetaAttached?: boolean;
|
||||
woNumber?: string;
|
||||
onSaveDetails?: (details: ServiceNotesDetails) => void;
|
||||
viewOnly?: boolean;
|
||||
|
|
@ -32,6 +35,8 @@ export function PMTypeCell({
|
|||
onSave,
|
||||
serviceNotes = "",
|
||||
extraServices = [],
|
||||
avetaRequired = false,
|
||||
avetaAttached = false,
|
||||
woNumber,
|
||||
onSaveDetails,
|
||||
viewOnly = false,
|
||||
|
|
@ -45,7 +50,7 @@ export function PMTypeCell({
|
|||
: pmTypes;
|
||||
|
||||
const extras = extraServices ?? [];
|
||||
const hasDetails = hasServiceNotesDetails(serviceNotes, extras);
|
||||
const hasDetails = hasServiceNotesDetails(serviceNotes, extras, avetaRequired);
|
||||
const notePreview = serviceNotes?.trim() ? truncateServiceNotesPreview(serviceNotes) : "";
|
||||
|
||||
const closePopover = () => {
|
||||
|
|
@ -61,6 +66,7 @@ export function PMTypeCell({
|
|||
pm={value}
|
||||
extraServices={extras}
|
||||
serviceNotes={serviceNotes ?? ""}
|
||||
avetaRequired={avetaRequired}
|
||||
woNumber={woNumber}
|
||||
pmTypes={pmTypes}
|
||||
viewOnly={viewOnly}
|
||||
|
|
@ -95,6 +101,7 @@ export function PMTypeCell({
|
|||
) : (
|
||||
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>—</span>
|
||||
)}
|
||||
{Boolean(avetaRequired) && <AvetaBadge size={11} attached={avetaAttached} />}
|
||||
</button>
|
||||
{modal}
|
||||
</>
|
||||
|
|
@ -109,6 +116,8 @@ export function PMTypeCell({
|
|||
q={q}
|
||||
notePreview={notePreview}
|
||||
extrasCount={extras.length}
|
||||
avetaRequired={avetaRequired}
|
||||
avetaAttached={avetaAttached}
|
||||
onOpenDetails={() => setModalOpen(true)}
|
||||
/>
|
||||
{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 { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||
|
||||
|
|
@ -6,6 +7,8 @@ type PmTypeDetailsTriggerProps = {
|
|||
q?: string;
|
||||
notePreview: string;
|
||||
extrasCount: number;
|
||||
avetaRequired?: boolean;
|
||||
avetaAttached?: boolean;
|
||||
onOpenDetails: () => void;
|
||||
};
|
||||
|
||||
|
|
@ -14,6 +17,8 @@ export function PmTypeDetailsTrigger({
|
|||
q,
|
||||
notePreview,
|
||||
extrasCount,
|
||||
avetaRequired = false,
|
||||
avetaAttached = false,
|
||||
onOpenDetails,
|
||||
}: PmTypeDetailsTriggerProps) {
|
||||
return (
|
||||
|
|
@ -41,6 +46,7 @@ export function PmTypeDetailsTrigger({
|
|||
>
|
||||
{value ? <HighlightText text={value} q={q} /> : "Select …"}
|
||||
</span>
|
||||
{Boolean(avetaRequired) && <AvetaBadge size={11} attached={avetaAttached} />}
|
||||
</span>
|
||||
{notePreview ? (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export type ServiceNotesDetails = {
|
|||
pm: string;
|
||||
extraServices: string[];
|
||||
serviceNotes: string;
|
||||
avetaRequired: boolean;
|
||||
};
|
||||
|
||||
type ServiceNotesDialogProps = {
|
||||
|
|
@ -17,6 +18,7 @@ type ServiceNotesDialogProps = {
|
|||
pm: string;
|
||||
extraServices: string[];
|
||||
serviceNotes: string;
|
||||
avetaRequired?: boolean;
|
||||
woNumber?: string;
|
||||
pmTypes: string[];
|
||||
viewOnly?: boolean;
|
||||
|
|
@ -29,6 +31,7 @@ export function ServiceNotesDialog({
|
|||
pm,
|
||||
extraServices,
|
||||
serviceNotes,
|
||||
avetaRequired = false,
|
||||
woNumber,
|
||||
pmTypes,
|
||||
viewOnly = false,
|
||||
|
|
@ -36,6 +39,7 @@ export function ServiceNotesDialog({
|
|||
}: ServiceNotesDialogProps) {
|
||||
const [sel, setSel] = useState<string[]>([]);
|
||||
const [note, setNote] = useState("");
|
||||
const [aveta, setAveta] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -44,8 +48,9 @@ export function ServiceNotesDialog({
|
|||
}
|
||||
setSel([pm, ...(extraServices ?? [])].filter((p, i, a) => !!p && a.indexOf(p) === i));
|
||||
setNote(serviceNotes ?? "");
|
||||
setAveta(Boolean(avetaRequired));
|
||||
setSearch("");
|
||||
}, [open, pm, extraServices, serviceNotes]);
|
||||
}, [open, pm, extraServices, serviceNotes, avetaRequired]);
|
||||
|
||||
const toggle = (value: string) => {
|
||||
setSel((cur) => (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value]));
|
||||
|
|
@ -87,6 +92,8 @@ export function ServiceNotesDialog({
|
|||
setNote={setNote}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
avetaRequired={aveta}
|
||||
onAvetaRequiredChange={setAveta}
|
||||
/>
|
||||
</fieldset>
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
|
|
@ -116,6 +123,7 @@ export function ServiceNotesDialog({
|
|||
pm: sel[0] ?? "",
|
||||
extraServices: sel.slice(1),
|
||||
serviceNotes: note,
|
||||
avetaRequired: aveta,
|
||||
});
|
||||
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 {
|
||||
DAY_PAGE_SIZE_OPTIONS,
|
||||
asDayPageSize,
|
||||
formatDayPaginationSummary,
|
||||
type DayPageSize,
|
||||
} from "@/domain/work-orders/utils/day-pagination";
|
||||
|
|
@ -16,6 +17,7 @@ type WoDayPaginationFooterProps = {
|
|||
onPageSizeChange: (pageSize: DayPageSize) => void;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export function WoDayPaginationFooter({
|
||||
|
|
@ -27,6 +29,7 @@ export function WoDayPaginationFooter({
|
|||
onPageSizeChange,
|
||||
onPrev,
|
||||
onNext,
|
||||
testId = "wo-day-pagination",
|
||||
}: WoDayPaginationFooterProps) {
|
||||
const canPrev = pageIndex > 0;
|
||||
const canNext = pageIndex < pageCount - 1;
|
||||
|
|
@ -37,7 +40,7 @@ export function WoDayPaginationFooter({
|
|||
<nav
|
||||
className="flex shrink-0 items-center justify-between gap-3"
|
||||
aria-label="Day pagination"
|
||||
data-testid="wo-day-pagination"
|
||||
data-testid={testId}
|
||||
>
|
||||
<Text variant="caption" tone="muted">
|
||||
{summary}
|
||||
|
|
@ -51,7 +54,7 @@ export function WoDayPaginationFooter({
|
|||
aria-label="Rows per page"
|
||||
className="rounded-md border border-border bg-card px-2 py-1 text-xs"
|
||||
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) => (
|
||||
<option key={option} value={option}>
|
||||
|
|
@ -67,7 +70,7 @@ export function WoDayPaginationFooter({
|
|||
<Text variant="caption">{pageLabel}</Text>
|
||||
<NavArrowButton
|
||||
aria-label="Next page"
|
||||
data-testid="wo-day-pagination-next"
|
||||
data-testid={`${testId}-next`}
|
||||
disabled={!canNext}
|
||||
onClick={onNext}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import { Fragment, type ReactNode } from "react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
UNASSIGNED_DAY_KEY,
|
||||
type WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||
import { WO_TABLE_COLS } from "./wo-table-cols";
|
||||
import { WoTableSectionHeader } from "./wo-table-section-header";
|
||||
|
||||
type WoTableBodyProps = {
|
||||
|
|
@ -19,60 +14,8 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
|
|||
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 (
|
||||
<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) => (
|
||||
<Fragment key={group.key}>
|
||||
<WoTableSectionHeader
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ export function WoTableInlineRowCells({
|
|||
pmTypes={pmTypes}
|
||||
serviceNotes={draft.serviceNotes}
|
||||
extraServices={draft.extraServices}
|
||||
avetaRequired={draft.avetaRequired}
|
||||
woNumber={draft.woNumber}
|
||||
onSave={(pm) => onPatch({ pm })}
|
||||
onSaveDetails={(d) =>
|
||||
|
|
@ -185,6 +186,7 @@ export function WoTableInlineRowCells({
|
|||
pm: d.pm,
|
||||
extraServices: d.extraServices,
|
||||
serviceNotes: d.serviceNotes,
|
||||
avetaRequired: d.avetaRequired,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ export function WoTableRowCells({
|
|||
rescheduleCount={row.rescheduleCount}
|
||||
carriedOver={row.carriedOver}
|
||||
isAddOn={row.isAddOn}
|
||||
originalDate={row.originalDate}
|
||||
q={search}
|
||||
currentId={row.id}
|
||||
allOrders={allRows}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ export function WoTableRowServiceCells({
|
|||
q={search}
|
||||
serviceNotes={row.serviceNotes}
|
||||
extraServices={row.extraServices}
|
||||
avetaRequired={row.avetaRequired}
|
||||
avetaAttached={row.hasAvetaDocument}
|
||||
woNumber={row.woNumber}
|
||||
viewOnly={viewOnly}
|
||||
onSave={(pm) => onPatchRow({ pm })}
|
||||
|
|
@ -57,6 +59,7 @@ export function WoTableRowServiceCells({
|
|||
pm: d.pm,
|
||||
extraServices: d.extraServices,
|
||||
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="min-h-0 flex-1 overflow-auto">{children}</div>
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import type {
|
|||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} 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 { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
|
||||
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) {
|
||||
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) {
|
||||
return (
|
||||
|
|
@ -193,10 +192,7 @@ export function WoTable({
|
|||
);
|
||||
}
|
||||
|
||||
const showSearchEmpty =
|
||||
!tableData.advActive &&
|
||||
tableData.noSearchResults &&
|
||||
isUnassignedQueueReady(tableData.unassignedLoading, tableData.unassignedError);
|
||||
const showSearchEmpty = !tableData.advActive && tableData.noSearchResults;
|
||||
|
||||
if (showSearchEmpty) {
|
||||
return (
|
||||
|
|
@ -210,7 +206,7 @@ export function WoTable({
|
|||
|
||||
return (
|
||||
<>
|
||||
<WoTableShell footer={WoTableDayFooter({ tableData })}>
|
||||
<WoTableShell footer={WoTablePager({ tableData })}>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ export function WoTableSkeleton({
|
|||
>
|
||||
<WoTableHead />
|
||||
<tbody>
|
||||
<DaySkeletonSection label="Unassigned" rows={1} />
|
||||
<DaySkeletonSection label={formatDayTabLabel(dayIso)} rows={rowsPerDay} />
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
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 {
|
||||
DLG_INPUT_CLS,
|
||||
DLG_INPUT_STYLE,
|
||||
|
|
@ -14,6 +15,8 @@ type WizardServiceNotesFieldsProps = {
|
|||
setNote: (v: string) => void;
|
||||
search: string;
|
||||
setSearch: (v: string) => void;
|
||||
avetaRequired?: boolean;
|
||||
onAvetaRequiredChange?: (value: boolean) => void;
|
||||
};
|
||||
|
||||
function SvcSectionLabel({ children }: { children: ReactNode }) {
|
||||
|
|
@ -43,6 +46,8 @@ export function WizardServiceNotesFields({
|
|||
setNote,
|
||||
search,
|
||||
setSearch,
|
||||
avetaRequired = false,
|
||||
onAvetaRequiredChange,
|
||||
}: WizardServiceNotesFieldsProps) {
|
||||
const filtered = search
|
||||
? services.filter((s) => s.value.toLowerCase().includes(search.toLowerCase()))
|
||||
|
|
@ -124,6 +129,11 @@ export function WizardServiceNotesFields({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onAvetaRequiredChange != null && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<AvetaCheckbox checked={avetaRequired} onChange={onAvetaRequiredChange} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<SvcSectionLabel>Notes / Issue Detail</SvcSectionLabel>
|
||||
<textarea
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ export function WizardStepLocationServiceSelect({
|
|||
setNote={(v) => onUpdate("pmNote", v)}
|
||||
search={svcSearch}
|
||||
setSearch={onSvcSearchChange}
|
||||
avetaRequired={draft.avetaRequired}
|
||||
onAvetaRequiredChange={(value) => onUpdate("avetaRequired", value)}
|
||||
/>
|
||||
</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 { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params";
|
||||
import {
|
||||
useWorkOrdersBoard,
|
||||
useWorkOrdersBoardSearch,
|
||||
useWorkOrdersUnassignedQueue,
|
||||
} from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import {
|
||||
useWorkOrderLocations,
|
||||
useWorkOrderVendors,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
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";
|
||||
|
||||
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
||||
|
||||
const EMPTY_UNASSIGNED_QUEUE: WorkOrderTableRow[] = [];
|
||||
|
||||
export type WorkOrdersBoardSource = {
|
||||
advActive: boolean;
|
||||
data: WorkOrderBoardListResult | undefined;
|
||||
unassignedQueue: WorkOrderTableRow[];
|
||||
unassignedError: Error | null;
|
||||
unassignedLoading: boolean;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
|
|
@ -74,35 +67,13 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar
|
|||
error: searchError,
|
||||
refetch: refetchSearch,
|
||||
} = 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 {
|
||||
advActive,
|
||||
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,
|
||||
isFetching: advActive ? searchFetching : boardFetching || queueFetching,
|
||||
isFetching: advActive ? searchFetching : boardFetching,
|
||||
error: advActive ? searchError : boardError,
|
||||
refetch: advActive ? refetchSearch : refetchBoardWithQueue,
|
||||
refetch: advActive ? refetchSearch : refetchBoard,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||
import {
|
||||
DEFAULT_DAY_PAGE_SIZE,
|
||||
findItemPageIndex,
|
||||
totalPageCount,
|
||||
type DayPageSize,
|
||||
} from "@/domain/work-orders/utils/day-pagination";
|
||||
|
||||
|
|
@ -44,6 +45,8 @@ export function useWorkOrdersDayPagination({
|
|||
onRevealed(revealedId);
|
||||
}, [pendingRevealId, dayItems, pageSize, onRevealed]);
|
||||
|
||||
const pageCount = totalPageCount(dayItems.length, pageSize);
|
||||
|
||||
const setPageSize = useCallback((next: DayPageSize) => {
|
||||
setPageSizeState(next);
|
||||
setPage(0);
|
||||
|
|
@ -54,8 +57,8 @@ export function useWorkOrdersDayPagination({
|
|||
}, []);
|
||||
|
||||
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) => {
|
||||
setPendingRevealId(id);
|
||||
|
|
|
|||
|
|
@ -10,10 +10,8 @@ import {
|
|||
countAdvancedFilters,
|
||||
getInitialAdvancedFilters,
|
||||
} from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||
import {
|
||||
BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||
toBoardQueryParams,
|
||||
} from "@/domain/work-orders/utils/board-query-params";
|
||||
import { toBoardQueryParams } from "@/domain/work-orders/utils/board-query-params";
|
||||
import { asDayPageSize, DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination";
|
||||
import { compactWeekLabel, currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||
import {
|
||||
firstWeekday,
|
||||
|
|
@ -27,7 +25,7 @@ export function useWorkOrdersListFilters() {
|
|||
const [advOpen, setAdvOpen] = useState(false);
|
||||
const [advApplied, setAdvApplied] = useState<WorkOrderAdvancedFilters | null>(null);
|
||||
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 [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
|
||||
const [weekMonday, setWeekMondayState] = useState(currentWeekMonday);
|
||||
|
|
@ -67,9 +65,7 @@ export function useWorkOrdersListFilters() {
|
|||
setPage(0);
|
||||
};
|
||||
|
||||
const setSelectedDayIso = (value: string) => {
|
||||
setSelectedDayIsoState(value);
|
||||
};
|
||||
const setSelectedDayIso = setSelectedDayIsoState;
|
||||
|
||||
const navigateToDay = (dateIso: string) => {
|
||||
const tab = scheduledDayTab(dateIso);
|
||||
|
|
@ -110,6 +106,11 @@ export function useWorkOrdersListFilters() {
|
|||
setPage(0);
|
||||
};
|
||||
|
||||
const setPageSize = (next: number) => {
|
||||
setPageSizeState(asDayPageSize(next));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
if (sortBy === column) {
|
||||
setSortDir((current) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useMemo } from "react";
|
||||
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 { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination";
|
||||
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 { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import {
|
||||
pickBoardPager,
|
||||
pageSelectedDayGroups,
|
||||
shouldShowDayPagination,
|
||||
type DayPageSize,
|
||||
} from "@/domain/work-orders/utils/day-pagination";
|
||||
import {
|
||||
buildSelectedDayGroup,
|
||||
isDayScopedSearchEmpty,
|
||||
isUnassignedQueueReady,
|
||||
} from "@/domain/work-orders/utils/week-day-nav-utils";
|
||||
import {
|
||||
composeBoardSections,
|
||||
filterByBarAndSearch,
|
||||
mergeWeeklyAndUnassignedQueue,
|
||||
rowIdSet,
|
||||
splitScheduledUnscheduled,
|
||||
} 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>;
|
||||
|
||||
const EMPTY_WEEKLY_ITEMS: WorkOrderTableRow[] = [];
|
||||
const EMPTY_UNASSIGNED: WorkOrderTableRow[] = [];
|
||||
|
||||
export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||
const {
|
||||
advActive,
|
||||
data,
|
||||
unassignedQueue,
|
||||
unassignedError,
|
||||
unassignedLoading,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
} = useWorkOrdersBoardSource(filters);
|
||||
const { advActive, data, isLoading, isFetching, error, refetch } =
|
||||
useWorkOrdersBoardSource(filters);
|
||||
|
||||
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({
|
||||
items,
|
||||
items: weeklyItems,
|
||||
weekMonday: filters.weekMonday,
|
||||
advActive,
|
||||
isFetching,
|
||||
|
|
@ -70,18 +54,12 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
return displayRows;
|
||||
}, [displayRows, filters.barFilters, filters.search, advActive]);
|
||||
|
||||
const { unassigned, scheduled, unscheduled } = useMemo(() => {
|
||||
const { scheduled, unscheduled } = useMemo(() => {
|
||||
if (advActive) {
|
||||
const split = splitScheduledUnscheduled(advResults);
|
||||
return { unassigned: [], ...split };
|
||||
return splitScheduledUnscheduled(advResults);
|
||||
}
|
||||
return composeBoardSections({
|
||||
displayRows,
|
||||
weekRows: filtered,
|
||||
weeklyIds,
|
||||
queueIds,
|
||||
});
|
||||
}, [advActive, advResults, displayRows, filtered, weeklyIds, queueIds]);
|
||||
return splitScheduledUnscheduled(filtered);
|
||||
}, [advActive, advResults, filtered]);
|
||||
|
||||
const dayGroups = useMemo(
|
||||
() =>
|
||||
|
|
@ -105,16 +83,16 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
const searchActive = filters.search.trim() !== "";
|
||||
const displayGroups = pagedDay.groups;
|
||||
const dayTotalCount = pagedDay.totalCount;
|
||||
const dayVisibleCount = pagedDay.groups[0]?.items.length ?? 0;
|
||||
const noSearchResults =
|
||||
!advActive &&
|
||||
isDayScopedSearchEmpty({
|
||||
searchActive,
|
||||
unassignedCount: unassigned.length,
|
||||
unassignedCount: 0,
|
||||
dayItemCount: dayTotalCount,
|
||||
unassignedQueueReady: isUnassignedQueueReady(unassignedLoading, unassignedError),
|
||||
unassignedQueueReady: true,
|
||||
});
|
||||
const isEmptyWeek =
|
||||
!advActive && scheduled.length === 0 && unassigned.length === 0 && !searchActive;
|
||||
const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
|
||||
|
||||
const todayKey =
|
||||
filters.weekMonday === currentWeekMonday()
|
||||
|
|
@ -126,6 +104,35 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
[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 {
|
||||
rows: displayRows,
|
||||
isLoading,
|
||||
|
|
@ -134,9 +141,10 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
refetch,
|
||||
advActive,
|
||||
advResults,
|
||||
unassigned,
|
||||
unassignedError,
|
||||
unassignedLoading,
|
||||
unassigned: EMPTY_UNASSIGNED,
|
||||
unassignedTotalCount: 0,
|
||||
unassignedError: null,
|
||||
unassignedLoading: false,
|
||||
unscheduled,
|
||||
displayGroups,
|
||||
searchActive,
|
||||
|
|
@ -145,22 +153,28 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
todayKey,
|
||||
...localUi,
|
||||
selectedRow,
|
||||
scopeTotalCount: data?.totalCount ?? 0,
|
||||
totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayTotalCount,
|
||||
scopeTotalCount,
|
||||
totalCount: listTotalCount,
|
||||
search: filters.search,
|
||||
showDayPagination: shouldShowDayPagination({
|
||||
flatListMode: advActive,
|
||||
isLoading,
|
||||
noSearchResults,
|
||||
}),
|
||||
dayPageSize: pagination.pageSize,
|
||||
dayPageIndex: pagedDay.pageIndex,
|
||||
dayPageCount: pagedDay.pageCount,
|
||||
dayItemCount: pagedDay.totalCount,
|
||||
dayFooterLabel: displayGroups[0]?.label ?? "",
|
||||
setDayPageSize: pagination.setPageSize,
|
||||
goToPrevDayPage: pagination.goToPrevPage,
|
||||
goToNextDayPage: pagination.goToNextPage,
|
||||
showDayPagination: pager.show,
|
||||
dayPageSize: pager.pageSize,
|
||||
dayPageIndex: pager.pageIndex,
|
||||
dayPageCount: pager.pageCount,
|
||||
dayItemCount: pager.itemCount,
|
||||
dayFooterLabel: pager.footerLabel,
|
||||
setDayPageSize: (next: DayPageSize) => {
|
||||
if (advActive) {
|
||||
filters.setPageSize(next);
|
||||
return;
|
||||
}
|
||||
pagination.setPageSize(next);
|
||||
},
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ export default function WorkOrdersListPage() {
|
|||
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
||||
<PageHeader
|
||||
surface
|
||||
eyebrow="OPERATIONS"
|
||||
title="Work Orders"
|
||||
belowTitle={
|
||||
<DispatcherFilter
|
||||
|
|
@ -68,7 +67,7 @@ export default function WorkOrdersListPage() {
|
|||
{filters.advApplied != null && (
|
||||
<WorkOrdersAdvancedFiltersBanner
|
||||
applied={filters.advApplied}
|
||||
totalCount={tableData.totalCount}
|
||||
totalCount={tableData.scopeTotalCount}
|
||||
sites={page.locations}
|
||||
users={page.users}
|
||||
onClearChip={filters.clearAdvancedFilterChip}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@ export function NavArrowButton({
|
|||
type={type}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-card transition-colors disabled:cursor-not-allowed",
|
||||
disabled ? "text-border" : "text-muted-foreground",
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-card transition-colors",
|
||||
disabled
|
||||
? "cursor-not-allowed text-border"
|
||||
: "cursor-pointer text-muted-foreground hover:bg-(--color-bg-muted) hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,11 @@
|
|||
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";
|
||||
|
||||
const UNASSIGNED_QUEUE_MAX_PAGES = 50;
|
||||
|
||||
/** Exhausts GET /board/search pages for the global Unassigned pin (SH-165). */
|
||||
export async function fetchUnassignedBoardRows(): Promise<WorkOrderTableRow[]> {
|
||||
const rows: WorkOrderTableRow[] = [];
|
||||
let page = 0;
|
||||
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;
|
||||
/** One GET /board/search page for Dispatcher=Unassigned / Advanced Filters. */
|
||||
export async function fetchUnassignedBoardPage(
|
||||
page = 0,
|
||||
pageSize = 50,
|
||||
): Promise<WorkOrderBoardListResult> {
|
||||
return workOrdersApi.searchBoard(toUnassignedQueueQueryParams(page, pageSize));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,17 +153,17 @@ export const workOrdersApi = {
|
|||
|
||||
const scheduled = mapWorkOrderTableRows(payload.scheduled ?? []);
|
||||
const unscheduled = mapWorkOrderTableRows(payload.unscheduled ?? []);
|
||||
const returnedCount = payload.counts?.returned ?? scheduled.length;
|
||||
const totalCount = payload.counts?.total ?? returnedCount;
|
||||
const returnedCount = payload.counts?.returned || scheduled.length;
|
||||
const totalCount = payload.counts?.total || scheduled.length;
|
||||
|
||||
return {
|
||||
items: [...unscheduled, ...scheduled],
|
||||
items: scheduled,
|
||||
scheduled,
|
||||
unscheduled,
|
||||
totalCount,
|
||||
returnedCount,
|
||||
page: 1,
|
||||
pageSize: scheduled.length + unscheduled.length,
|
||||
pageSize: scheduled.length,
|
||||
weekStart: payload.weekStart,
|
||||
weekEnd: payload.weekEnd,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -63,4 +63,8 @@ export function assignWizardServicePocFields(
|
|||
if (draft.severity != null) {
|
||||
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: "extraServices", field: "extraServices", toValue: (value) => JSON.stringify(value) },
|
||||
{ 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: "additionalContacts",
|
||||
|
|
|
|||
|
|
@ -99,11 +99,13 @@ function mapDocStatus(raw: unknown): CompletionDocStatus {
|
|||
}
|
||||
|
||||
function mapMediaCategory(raw: unknown): BoardMediaCategory {
|
||||
if (raw === "Aveta" || raw === "aveta") return "Aveta";
|
||||
const numeric = typeof raw === "number" ? raw : Number(raw);
|
||||
if (numeric === BOARD_MEDIA_CATEGORY_API.Before) return "Before";
|
||||
if (numeric === BOARD_MEDIA_CATEGORY_API.After) return "After";
|
||||
if (numeric === BOARD_MEDIA_CATEGORY_API.Extra) return "Extra";
|
||||
if (numeric === BOARD_MEDIA_CATEGORY_API.Completion) return "Completion";
|
||||
if (numeric === BOARD_MEDIA_CATEGORY_API.Aveta) return "Aveta";
|
||||
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 {
|
||||
const form = new FormData();
|
||||
|
|
@ -246,6 +248,7 @@ function classifyFileName(fileName: string, url: string): FileCategory {
|
|||
}
|
||||
|
||||
function boardCategoryToMediaCategory(category: BoardMediaCategory): MediaCategory {
|
||||
if (category === "Aveta") return "Aveta";
|
||||
if (category === "Extra") return "Other";
|
||||
if (category === "Completion") return "Report";
|
||||
return "Photo";
|
||||
|
|
|
|||
|
|
@ -368,6 +368,8 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
|||
rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0,
|
||||
carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0,
|
||||
isAddOn: readIsAddOn(item),
|
||||
avetaRequired: readBool(item, "avetaRequired", "AvetaRequired"),
|
||||
hasAvetaDocument: readBool(item, "hasAvetaDocument", "HasAvetaDocument"),
|
||||
originalWeek: readString(item, "originalWeek", "OriginalWeek"),
|
||||
originalDate: toDateInputValue(readString(item, "originalDate", "OriginalDate")),
|
||||
isPastDue: readBool(item, "isPastDue", "IsPastDue"),
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export interface WorkOrderAdvancedFilters {
|
|||
rescheduled: boolean;
|
||||
carriedOver: boolean;
|
||||
addOn: boolean;
|
||||
avetaOnly: boolean;
|
||||
pmTypes: Set<string>;
|
||||
vendorTechs: Set<string>;
|
||||
docs: Set<CompletionDocFilter>;
|
||||
|
|
@ -55,6 +56,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
|||
rescheduled: false,
|
||||
carriedOver: false,
|
||||
addOn: false,
|
||||
avetaOnly: false,
|
||||
pmTypes: new Set(),
|
||||
vendorTechs: 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 { 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 = {
|
||||
Before: 1,
|
||||
After: 2,
|
||||
Extra: 3,
|
||||
Completion: 4,
|
||||
Aveta: 5,
|
||||
} as const;
|
||||
|
||||
export type BoardMediaCategoryApi =
|
||||
(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 {
|
||||
id: number;
|
||||
|
|
@ -50,6 +51,10 @@ export interface WorkOrderBoardDetailInfoApi {
|
|||
pm?: string | null;
|
||||
serviceNotes?: 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;
|
||||
flagColor?: string | null;
|
||||
primaryDispatchId?: number | null;
|
||||
|
|
|
|||
|
|
@ -104,6 +104,8 @@ export interface WorkOrderBoardCreatePayload {
|
|||
extraServices?: string[];
|
||||
/** Issue detail from Service & Notes (`pmNote` in draft). */
|
||||
serviceNotes?: string;
|
||||
/** Per-WO Aveta compliance flag from Service & Notes. */
|
||||
avetaRequired?: boolean;
|
||||
locationId?: number;
|
||||
pocContactId?: number;
|
||||
pocName?: string;
|
||||
|
|
@ -142,6 +144,7 @@ export type WorkOrderBoardEditableField =
|
|||
| "completedDate"
|
||||
| "pm"
|
||||
| "serviceNotes"
|
||||
| "avetaRequired"
|
||||
| "extraServices"
|
||||
| "flagColor"
|
||||
| "additionalContacts"
|
||||
|
|
@ -155,6 +158,13 @@ export interface WorkOrderBoardRowApi {
|
|||
carriedOver?: number;
|
||||
/** Backend-authoritative Add-On indicator (SH-184; recalculated on schedule). */
|
||||
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;
|
||||
isPastDue?: boolean;
|
||||
siteCode?: string | null;
|
||||
|
|
@ -277,6 +287,8 @@ export interface WorkOrderBoardSearchQueryParams {
|
|||
rescheduled?: boolean;
|
||||
carriedOver?: boolean;
|
||||
addOn?: boolean;
|
||||
/** Only WOs flagged `avetaRequired`. */
|
||||
avetaOnly?: boolean;
|
||||
flagColors?: string[];
|
||||
internalOnly?: boolean;
|
||||
hasUplift?: boolean;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export type FileCategory = "image" | "video" | "pdf" | "doc" | "other";
|
||||
|
||||
export type MediaCategory =
|
||||
"Photo" | "Video" | "Receipt" | "Quote" | "Invoice" | "Report" | "Other";
|
||||
"Photo" | "Video" | "Receipt" | "Quote" | "Invoice" | "Report" | "Aveta" | "Other";
|
||||
|
||||
export interface MediaFile {
|
||||
id: string;
|
||||
|
|
@ -9,8 +9,8 @@ export interface MediaFile {
|
|||
fileType: FileCategory;
|
||||
url: string;
|
||||
category: MediaCategory;
|
||||
/** Board purpose label (Before/After/Extra/Completion). Null/undefined = needs categorization. */
|
||||
purpose?: "Before" | "After" | "Extra" | "Completion" | null;
|
||||
/** Board purpose label (Before/After/Extra/Completion/Aveta). Null/undefined = needs categorization. */
|
||||
purpose?: "Before" | "After" | "Extra" | "Completion" | "Aveta" | null;
|
||||
/** True when media is a legacy Before/After/Completion column (not deletable). */
|
||||
isLegacy?: boolean;
|
||||
/** 0–100 while uploading; undefined when idle/complete. */
|
||||
|
|
@ -26,6 +26,7 @@ export const MEDIA_CATEGORIES: MediaCategory[] = [
|
|||
"Quote",
|
||||
"Invoice",
|
||||
"Report",
|
||||
"Aveta",
|
||||
"Other",
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ export interface WorkOrderTableRow {
|
|||
* recalculated on schedule week moves / clear; FE only reflects API value.
|
||||
*/
|
||||
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;
|
||||
originalDate: string;
|
||||
isPastDue: boolean;
|
||||
|
|
@ -87,6 +91,7 @@ export interface WorkOrderTableDraft {
|
|||
pm: string;
|
||||
serviceNotes: string;
|
||||
extraServices: string[];
|
||||
avetaRequired?: boolean;
|
||||
vendorId: string;
|
||||
company: string;
|
||||
tech: string;
|
||||
|
|
@ -118,6 +123,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
|
|||
pm: "",
|
||||
serviceNotes: "",
|
||||
extraServices: [],
|
||||
avetaRequired: false,
|
||||
vendorId: "",
|
||||
company: "",
|
||||
tech: "",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ export interface WorkOrderWizardDraft {
|
|||
extraServices: string[];
|
||||
/** Free-text note attached to the service (Service & Notes). */
|
||||
pmNote: string;
|
||||
/** Per-WO Aveta compliance flag from Service & Notes. */
|
||||
avetaRequired: boolean;
|
||||
vendorId: string;
|
||||
vendorName: string;
|
||||
techPhone: string;
|
||||
|
|
@ -82,6 +84,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
|||
pm: "",
|
||||
extraServices: [],
|
||||
pmNote: "",
|
||||
avetaRequired: false,
|
||||
vendorId: "",
|
||||
vendorName: "",
|
||||
techPhone: "",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
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 type {
|
||||
WorkOrdersListParams,
|
||||
|
|
@ -11,7 +10,6 @@ import type {
|
|||
WorkOrderBoardQueryParams,
|
||||
WorkOrderBoardSearchQueryParams,
|
||||
} 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";
|
||||
|
||||
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> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.workOrders.dispatchers(),
|
||||
|
|
|
|||
|
|
@ -231,6 +231,14 @@ export function appendIndicatorChips(
|
|||
clear: (current) => ({ ...current, addOn: false }),
|
||||
});
|
||||
}
|
||||
if (filters.avetaOnly) {
|
||||
chips.push({
|
||||
key: "aveta",
|
||||
group: "Aveta",
|
||||
value: "Required",
|
||||
clear: (current) => ({ ...current, avetaOnly: false }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function appendFlagAndInternalChips(
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ const matchesRescheduled: AdvancedFilterPredicate = (row, filters) =>
|
|||
const matchesCarriedOver: AdvancedFilterPredicate = (row, filters) =>
|
||||
!filters.carriedOver || row.carriedOver >= 2;
|
||||
|
||||
const matchesAvetaOnly: AdvancedFilterPredicate = (row, filters) =>
|
||||
!filters.avetaOnly || row.avetaRequired === true;
|
||||
|
||||
const matchesInternalOnly: AdvancedFilterPredicate = (row, filters) =>
|
||||
!filters.internalOnly || row.woNumber.toUpperCase().startsWith("SH");
|
||||
|
||||
|
|
@ -68,6 +71,7 @@ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [
|
|||
matchesDoc,
|
||||
matchesRescheduled,
|
||||
matchesCarriedOver,
|
||||
matchesAvetaOnly,
|
||||
matchesInternalOnly,
|
||||
matchesFlagColors,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool
|
|||
filters.rescheduled,
|
||||
filters.carriedOver,
|
||||
filters.addOn,
|
||||
filters.avetaOnly,
|
||||
Boolean(filters.pmTypes?.size),
|
||||
Boolean(filters.vendorTechs?.size),
|
||||
Boolean(filters.docs?.size),
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ function tryPastDueRule({ fieldKey, actionKey }: AuditActionRuleContext): string
|
|||
fieldKey.includes("pastdue") || actionKey.includes("pastdue") || actionKey === "markedpastdue";
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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 { 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. */
|
||||
export const ADVANCED_SEARCH_ALL_WEEKS_FROM = "2000-01-01";
|
||||
|
|
@ -116,6 +116,7 @@ function mapSearchFacetParams(
|
|||
| "rescheduled"
|
||||
| "carriedOver"
|
||||
| "addOn"
|
||||
| "avetaOnly"
|
||||
| "flagColors"
|
||||
| "internalOnly"
|
||||
| "hasUplift"
|
||||
|
|
@ -126,6 +127,7 @@ function mapSearchFacetParams(
|
|||
rescheduled: filters.rescheduled || undefined,
|
||||
carriedOver: filters.carriedOver || undefined,
|
||||
addOn: filters.addOn || undefined,
|
||||
avetaOnly: filters.avetaOnly || undefined,
|
||||
flagColors: filters.flagColors?.size ? [...filters.flagColors] : undefined,
|
||||
internalOnly: filters.internalOnly || 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. */
|
||||
export function toUnassignedQueueQueryParams(page = 0): WorkOrderBoardSearchQueryParams {
|
||||
export function toUnassignedQueueQueryParams(
|
||||
page = 0,
|
||||
pageSize = BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||
): WorkOrderBoardSearchQueryParams {
|
||||
return {
|
||||
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],
|
||||
statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES],
|
||||
datePreset: "Custom",
|
||||
|
|
|
|||
|
|
@ -31,13 +31,24 @@ export function findItemPageIndex<T extends { id: string | number }>(
|
|||
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>(
|
||||
items: readonly T[],
|
||||
pageIndex: number,
|
||||
pageSize: number,
|
||||
): PaginatedSlice<T> {
|
||||
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 start = page * size;
|
||||
return {
|
||||
|
|
@ -47,6 +58,8 @@ export function paginateItems<T>(
|
|||
};
|
||||
}
|
||||
|
||||
export const SEARCH_PAGER_FOOTER_LABEL = "Results";
|
||||
|
||||
export function shouldShowDayPagination(input: {
|
||||
flatListMode: boolean;
|
||||
isLoading: boolean;
|
||||
|
|
@ -55,6 +68,85 @@ export function shouldShowDayPagination(input: {
|
|||
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 {
|
||||
const label = dayLabel.trim().toUpperCase();
|
||||
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(
|
||||
serviceNotes?: string | null,
|
||||
extraServices?: string[] | null,
|
||||
avetaRequired?: boolean | null,
|
||||
): 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). */
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export const SLIDE_OVER_EDIT_KEYS = [
|
|||
"pm",
|
||||
"serviceNotes",
|
||||
"extraServices",
|
||||
"avetaRequired",
|
||||
"additionalContacts",
|
||||
"vendorId",
|
||||
// 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,
|
||||
boardSearch: (params?: WorkOrderBoardSearchQueryParams) =>
|
||||
[...queryKeys.workOrders.all, "boardSearch", params ?? {}] as const,
|
||||
unassignedQueue: () => [...queryKeys.workOrders.all, "unassignedQueue"] as const,
|
||||
byLocation: (locationId: string | number, search = "") =>
|
||||
[...queryKeys.workOrders.all, "byLocation", locationId, search] as const,
|
||||
detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const,
|
||||
|
|
|
|||
|
|
@ -39,12 +39,18 @@ describe("handlePaginatedResponse", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("handles plain arrays", () => {
|
||||
const result = handlePaginatedResponse([{ id: 1 }], { page: 1, pageSize: 10 });
|
||||
it("reads total as an alias of totalCount", () => {
|
||||
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(1);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.pageSize).toBe(10);
|
||||
expect(result.totalCount).toBe(1778);
|
||||
expect(result.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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} />,
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
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[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)", () => {
|
||||
const onRevealed = vi.fn();
|
||||
const manyItems = Array.from({ length: 60 }, (_, index) => ({ id: `wo-${index}` }));
|
||||
const base = {
|
||||
weekMonday: "2026-08-17",
|
||||
selectedDayIso: "2026-08-17",
|
||||
advActive: false,
|
||||
dayItems: [] as { id: string }[],
|
||||
dayItems: manyItems,
|
||||
onRevealed,
|
||||
};
|
||||
|
||||
|
|
@ -58,12 +59,19 @@ describe("useWorkOrdersDayPagination (SH-241)", () => {
|
|||
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", () => {
|
||||
const dayItems = Array.from({ length: 60 }, (_, index) => ({ id: `wo-${index}` }));
|
||||
const revealed = vi.fn();
|
||||
const { result, rerender } = renderHook(
|
||||
(props: typeof base) => useWorkOrdersDayPagination(props),
|
||||
{ initialProps: { ...base, onRevealed: revealed } },
|
||||
{ initialProps: { ...base, dayItems: [] as { id: string }[], onRevealed: revealed } },
|
||||
);
|
||||
|
||||
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("Page 1 of 2")).toBeInTheDocument();
|
||||
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");
|
||||
expect(onPageSizeChange).toHaveBeenCalledWith(25);
|
||||
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)", () => {
|
||||
it("shows empty search only after the Unassigned queue has settled empty", () => {
|
||||
const { getByText, queryByText } = renderTable(tableData());
|
||||
describe("WoTable search empty (SH-241)", () => {
|
||||
it("shows empty search when the selected day has no matches", () => {
|
||||
const { getByText, queryByRole } = renderTable(tableData());
|
||||
|
||||
expect(getByText("No work orders match your search")).toBeInTheDocument();
|
||||
expect(queryByText(/Loading unassigned work orders/)).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();
|
||||
expect(queryByRole("button", { name: /Unassigned/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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 userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
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 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 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 {
|
||||
return {
|
||||
|
|
@ -115,7 +110,7 @@ function baseTableData(
|
|||
|
||||
const DISPATCHER = { id: "sarah", name: "Sarah Chen", initials: "SC", color: "#3366cc" };
|
||||
|
||||
function UnassignedAssignHarness({
|
||||
function DayAssignHarness({
|
||||
initialRow,
|
||||
patchBoardField,
|
||||
}: {
|
||||
|
|
@ -125,49 +120,38 @@ function UnassignedAssignHarness({
|
|||
payload: { field: string; value?: string | null },
|
||||
) => Promise<WorkOrderTableRow>;
|
||||
}) {
|
||||
const [rows, setRows] = useState([initialRow]);
|
||||
const weeklyIds = rowIdSet(rows);
|
||||
const sections = useMemo(
|
||||
() =>
|
||||
composeBoardSections({
|
||||
displayRows: rows,
|
||||
weekRows: rows,
|
||||
weeklyIds,
|
||||
queueIds: weeklyIds,
|
||||
}),
|
||||
[rows, weeklyIds],
|
||||
);
|
||||
const [row, setRow] = useState(initialRow);
|
||||
const tableData = baseTableData({
|
||||
unassigned: sections.unassigned,
|
||||
unscheduled: sections.unscheduled,
|
||||
displayGroups: buildDayGroups(sections.scheduled),
|
||||
displayGroups: [
|
||||
{
|
||||
key: "06/02",
|
||||
label: "TUE · 06/02",
|
||||
date: "2026-06-02",
|
||||
items: [row],
|
||||
totalCount: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<table>
|
||||
<WoTableBody
|
||||
tableData={tableData}
|
||||
renderRow={(row) => (
|
||||
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
||||
<td>{row.woNumber}</td>
|
||||
renderRow={(item) => (
|
||||
<tr key={String(item.id)} data-testid={`row-${item.id}`}>
|
||||
<td>{item.woNumber}</td>
|
||||
<td>
|
||||
<AssignedToCell
|
||||
dispatcherId={row.dispatcherId}
|
||||
dispatcherId={item.dispatcherId}
|
||||
users={[DISPATCHER]}
|
||||
highlight={!row.dispatcherId}
|
||||
highlight={!item.dispatcherId}
|
||||
onSave={async (id, name) => {
|
||||
const ops = expandTablePatchToBoardFieldOps({
|
||||
dispatcherId: id,
|
||||
dispatcherName: name,
|
||||
});
|
||||
const patched = await patchBoardField(row.id, ops[0]!);
|
||||
setRows((current) =>
|
||||
current.map((item) =>
|
||||
String(item.id) === String(row.id)
|
||||
? { ...patched, dispatcherId: id, dispatcherName: name }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
const patched = await patchBoardField(item.id, ops[0]!);
|
||||
setRow({ ...patched, dispatcherId: id, dispatcherName: name });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
|
|
@ -179,25 +163,9 @@ function UnassignedAssignHarness({
|
|||
);
|
||||
}
|
||||
|
||||
describe("WoTableBody Unassigned queue (SH-165)", () => {
|
||||
it("renders Unassigned above the selected day without Unscheduled (SH-240)", () => {
|
||||
describe("WoTableBody weekly board (AAP day view)", () => {
|
||||
it("renders the selected day and keeps dated-unassigned inline, with no Unassigned pin", () => {
|
||||
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({
|
||||
id: "day-assigned",
|
||||
woNumber: "25023",
|
||||
|
|
@ -205,142 +173,19 @@ describe("WoTableBody Unassigned queue (SH-165)", () => {
|
|||
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(
|
||||
<table>
|
||||
<WoTableBody
|
||||
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) => (
|
||||
<tr key={String(row.id)} data-testid={`row-${row.id}`}>
|
||||
|
|
@ -352,32 +197,15 @@ describe("WoTableBody Unassigned queue (SH-165)", () => {
|
|||
</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", () => {
|
||||
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 () => {
|
||||
it("assigns a dated-unassigned row in the day group without a pin section", async () => {
|
||||
const user = userEvent.setup();
|
||||
const pinned = makeRow({
|
||||
id: "u-pin",
|
||||
|
|
@ -391,17 +219,14 @@ describe("WoTableBody Unassigned queue (SH-165)", () => {
|
|||
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();
|
||||
await user.click(screen.getByRole("button", { name: /Select/ }));
|
||||
await user.click(screen.getByRole("button", { name: /Sarah Chen/ }));
|
||||
|
||||
expect(patchBoardField).toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Unassigned, 0 work orders, expanded" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Unassigned/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("row-u-pin")).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: /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(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 {
|
||||
items,
|
||||
scheduled: [],
|
||||
unscheduled: [],
|
||||
totalCount,
|
||||
returnedCount: items.length,
|
||||
page: 0,
|
||||
page,
|
||||
pageSize: BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
describe("fetchUnassignedBoardRows", () => {
|
||||
describe("fetchUnassignedBoardPage", () => {
|
||||
beforeEach(() => {
|
||||
searchBoard.mockReset();
|
||||
});
|
||||
|
||||
it("pages until totalCount is exhausted", async () => {
|
||||
const first = Array.from({ length: BOARD_SEARCH_MAX_PAGE_SIZE }, (_, index) => ({
|
||||
id: `a-${index}`,
|
||||
}));
|
||||
const second = [{ id: "b-0" }];
|
||||
searchBoard
|
||||
.mockResolvedValueOnce(pageResult(first, 101))
|
||||
.mockResolvedValueOnce(pageResult(second, 101));
|
||||
it("fetches a single search page instead of exhausting totalCount", async () => {
|
||||
const first = Array.from({ length: 50 }, (_, index) => ({ id: `a-${index}` }));
|
||||
searchBoard.mockResolvedValueOnce(pageResult(first, 1778, 0));
|
||||
|
||||
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({
|
||||
page: 0,
|
||||
pageSize: 50,
|
||||
dispatchers: ["__unassigned__"],
|
||||
datePreset: "Custom",
|
||||
});
|
||||
expect(searchBoard.mock.calls[1]?.[0]).toMatchObject({ page: 1 });
|
||||
expect(rows).toHaveLength(101);
|
||||
expect(result.items).toHaveLength(50);
|
||||
expect(result.totalCount).toBe(1778);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -245,6 +245,7 @@ describe("workOrdersApi.getBoard", () => {
|
|||
searchParams: expect.any(URLSearchParams),
|
||||
}),
|
||||
);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.returnedCount).toBe(1);
|
||||
expect(result.totalCount).toBe(2);
|
||||
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]?.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", () => {
|
||||
|
|
|
|||
|
|
@ -189,6 +189,28 @@ describe("mapWorkOrderBoardDetail", () => {
|
|||
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", () => {
|
||||
const after = mapBoardDetailMediaItem({
|
||||
id: 20,
|
||||
|
|
|
|||
|
|
@ -404,6 +404,16 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
|||
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", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
|
|
|||
|
|
@ -78,11 +78,13 @@ describe("expandTablePatchToBoardFieldOps", () => {
|
|||
pm: "Backflow Preventers",
|
||||
extraServices: ["Filter change", "Coil clean"],
|
||||
serviceNotes: "service notes",
|
||||
avetaRequired: true,
|
||||
}),
|
||||
).toEqual([
|
||||
{ field: "pm", value: "Backflow Preventers" },
|
||||
{ field: "extraServices", value: '["Filter change","Coil clean"]' },
|
||||
{ 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", () => {
|
||||
it.each([
|
||||
["primaryDispatchStatus", "Refused"],
|
||||
|
|
|
|||
|
|
@ -138,6 +138,18 @@ describe("clearAdvancedFilterGroup", () => {
|
|||
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", () => {
|
||||
const filters = {
|
||||
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ describe("toBoardQueryParams", () => {
|
|||
expect(params.overdue).toBe(true);
|
||||
});
|
||||
|
||||
it("omits types when filter is All", () => {
|
||||
it("omits types when the set is empty", () => {
|
||||
const params = toBoardQueryParams(baseBar, "");
|
||||
expect(params.types).toBeUndefined();
|
||||
expect(params.overdue).toBeUndefined();
|
||||
|
|
@ -90,6 +90,16 @@ describe("toBoardSearchQueryParams", () => {
|
|||
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", () => {
|
||||
const params = toBoardSearchQueryParams({
|
||||
filters: { ...EMPTY_ADVANCED_FILTERS, dateRange: "last-3-months" },
|
||||
|
|
@ -218,6 +228,11 @@ describe("toBoardSearchQueryParams", () => {
|
|||
expect(addOn.addOn).toBe(true);
|
||||
expect(addOn.carriedOver).toBeUndefined();
|
||||
|
||||
const aveta = toBoardSearchQueryParams({
|
||||
filters: { ...EMPTY_ADVANCED_FILTERS, avetaOnly: true },
|
||||
});
|
||||
expect(aveta.avetaOnly).toBe(true);
|
||||
|
||||
const service = toBoardSearchQueryParams({
|
||||
filters: { ...EMPTY_ADVANCED_FILTERS, pmTypes: new Set(["HVAC"]) },
|
||||
});
|
||||
|
|
@ -297,7 +312,7 @@ describe("toBoardSearchQueryParams", () => {
|
|||
filters: { ...EMPTY_ADVANCED_FILTERS },
|
||||
pageSize: 500,
|
||||
});
|
||||
expect(params.pageSize).toBe(100);
|
||||
expect(params.pageSize).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,17 +2,23 @@ import { describe, expect, it } from "vitest";
|
|||
import {
|
||||
DAY_PAGE_SIZE_OPTIONS,
|
||||
DEFAULT_DAY_PAGE_SIZE,
|
||||
asDayPageSize,
|
||||
findItemPageIndex,
|
||||
formatDayPaginationSummary,
|
||||
nextSearchPageIfOutOfRange,
|
||||
pageIndexForItem,
|
||||
paginateItems,
|
||||
pickBoardPager,
|
||||
shouldShowDayPagination,
|
||||
shouldShowSearchPagination,
|
||||
} from "@/domain/work-orders/utils/day-pagination";
|
||||
|
||||
describe("day pagination utils (SH-241)", () => {
|
||||
it("defaults to 50 and exposes 25/50/100/200", () => {
|
||||
expect(DEFAULT_DAY_PAGE_SIZE).toBe(50);
|
||||
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", () => {
|
||||
|
|
@ -70,6 +76,56 @@ describe("day pagination utils (SH-241)", () => {
|
|||
).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", () => {
|
||||
const items = Array.from({ length: 60 }, (_, index) => ({ id: `wo-${index}` }));
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
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", () => {
|
||||
|
|
|
|||