diff --git a/docs/work-orders/board-search-api.md b/docs/work-orders/board-search-api.md index eea2a0bd..f233640e 100644 --- a/docs/work-orders/board-search-api.md +++ b/docs/work-orders/board-search-api.md @@ -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` diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png index cdea35c5..fb36ec63 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png index d93b0653..e64d4c12 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png index 48da2932..f07dbc15 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png index 4a551b64..21809dbd 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png index 9d5372c7..a7109395 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png index 021a7ea2..b4da848d 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png index b014a299..6e98e3fa 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png index 9d314c96..e6298c3f 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png differ diff --git a/e2e/work-orders/advanced-filters.spec.ts b/e2e/work-orders/advanced-filters.spec.ts index b882415d..ef4b55f1 100644 --- a/e2e/work-orders/advanced-filters.spec.ts +++ b/e2e/work-orders/advanced-filters.spec.ts @@ -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, }) => { diff --git a/e2e/work-orders/board-day-pagination.spec.ts b/e2e/work-orders/board-day-pagination.spec.ts index 0bb3b322..0a3cff18 100644 --- a/e2e/work-orders/board-day-pagination.spec.ts +++ b/e2e/work-orders/board-day-pagination.spec.ts @@ -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(); diff --git a/src/api/paginated-response-fields.ts b/src/api/paginated-response-fields.ts index 157ffbc4..cfc31b8f 100644 --- a/src/api/paginated-response-fields.ts +++ b/src/api/paginated-response-fields.ts @@ -13,6 +13,8 @@ export function readTotalCount(record: Record, 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 ); diff --git a/src/app/(protected)/workorders/_components/detail/slide-over-header.tsx b/src/app/(protected)/workorders/_components/detail/slide-over-header.tsx index 0302625e..32facebb 100644 --- a/src/app/(protected)/workorders/_components/detail/slide-over-header.tsx +++ b/src/app/(protected)/workorders/_components/detail/slide-over-header.tsx @@ -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 || "—"} + + {Boolean(row.avetaRequired) && ( + + )} {isCancelled && ( = { other: Paperclip, }; +const EXTRA_DOC_PURPOSES: Array> = [ + "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 ( +
+ + + {file.name} + + {showCategory ? ( + + ) : ( + isAveta && ( + Aveta + ) + )} + {showDelete && ( + + )} +
+ ); +} + export function SlideOverExtrasTab({ uploads, isReadOnly, pending = false, + avetaRequired = false, onUpload, onRemove, + onCategorize, }: SlideOverExtrasTabProps) { const inputRef = useRef(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({ Extra Documents + {Boolean(avetaRequired) && ( + + + Aveta + + )} {!isReadOnly && ( @@ -122,40 +232,16 @@ export function SlideOverExtrasTab({ ) : (
- {uploads.map((file) => { - const ItemIcon = FILETYPE_ICON[file.fileType]; - const showDelete = !isReadOnly && canDeleteWorkOrderMedia(file); - return ( -
- - - {file.name} - - {showDelete && ( - - )} -
- ); - })} + {uploads.map((file) => ( + + ))}
)} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx index 46443207..2f497d10 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx @@ -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({ {source.pm} + {Boolean(source.avetaRequired) && ( + + )} ) : ( — diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx index 6cbd8006..dbb11c52 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx @@ -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({ /> - onDraftChange({ dueDate })} /> + onDraftChange({ dueDate })} + /> onDraftChange({ pm })} onSaveDetails={(details) => onDraftChange({ + pm: details.pm, serviceNotes: details.serviceNotes, extraServices: details.extraServices, + avetaRequired: details.avetaRequired, }) } /> diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts index bd0fab4b..94b60262 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts @@ -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 { diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx index 222ed0d6..ebd41392 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx @@ -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({ > { void state.uploadExtraFiles(files); }} onRemove={state.removeMedia} + onCategorize={state.categorizeMedia} /> )} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx index b21120b7..812b98b6 100644 --- a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx @@ -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({ + + ({ value: pmType, label: pmType }))} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx index dc2f1a8e..3bb9ecbd 100644 --- a/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx @@ -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 ( + + + + ); +} + export function AdvFlagInternalUpliftSections({ draft, setDraft, diff --git a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts index 68605ce8..5f911bd8 100644 --- a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts +++ b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts @@ -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, diff --git a/src/app/(protected)/workorders/_components/list/table/cells/aveta-badge.tsx b/src/app/(protected)/workorders/_components/list/table/cells/aveta-badge.tsx new file mode 100644 index 00000000..d5993fc5 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/aveta-badge.tsx @@ -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 ( + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/aveta-checkbox.tsx b/src/app/(protected)/workorders/_components/list/table/cells/aveta-checkbox.tsx new file mode 100644 index 00000000..6ebd6daa --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/aveta-checkbox.tsx @@ -0,0 +1,28 @@ +type AvetaCheckboxProps = { + checked: boolean; + onChange: (value: boolean) => void; + disabled?: boolean; +}; + +export function AvetaCheckbox({ checked, onChange, disabled = false }: AvetaCheckboxProps) { + return ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx index d6cae8fb..c212b795 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx @@ -43,6 +43,7 @@ export function DueDateCell({ type, value, q, highlight, onSave }: DueDateCellPr } : undefined } + aria-label="Due Date" aria-haspopup="dialog" > diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx index 9278fff1..b71f9c4f 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx @@ -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 ( - - {count} - - ); -} - -function AddOnPill({ when }: { when: boolean }) { - if (!when) { - return null; - } - - return ( - - Add-On - - ); -} - 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 ? : "-"} - - + + ); } @@ -187,8 +154,8 @@ export function EditableWONumberCell({
)} - - + + ); } diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx index bec126e3..be03e7fe 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx @@ -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({ ) : ( — )} + {Boolean(avetaRequired) && } {modal} @@ -109,6 +116,8 @@ export function PMTypeCell({ q={q} notePreview={notePreview} extrasCount={extras.length} + avetaRequired={avetaRequired} + avetaAttached={avetaAttached} onOpenDetails={() => setModalOpen(true)} /> {modal} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger.tsx index 4595a6b9..3bb7d5aa 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger.tsx @@ -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 ? : "Select …"} + {Boolean(avetaRequired) && } {notePreview ? ( ([]); 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} />
@@ -116,6 +123,7 @@ export function ServiceNotesDialog({ pm: sel[0] ?? "", extraServices: sel.slice(1), serviceNotes: note, + avetaRequired: aveta, }); onOpenChange(false); }} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/wo-counter-pills.tsx b/src/app/(protected)/workorders/_components/list/table/cells/wo-counter-pills.tsx new file mode 100644 index 00000000..3dad9c2d --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/wo-counter-pills.tsx @@ -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 ( + + + + {count} + + + ); +} + +export function CarriedOverCounterPill({ count }: { count: number }) { + if (count < 2) { + return null; + } + + return ( + + {count} + + ); +} + +export function AddOnPill({ when }: { when: boolean }) { + if (!when) { + return null; + } + + return ( + + Add-On + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-day-pagination-footer.tsx b/src/app/(protected)/workorders/_components/list/table/wo-day-pagination-footer.tsx index 850c7f55..6f5af206 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-day-pagination-footer.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-day-pagination-footer.tsx @@ -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({
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx index 0664eab6..8af5b8b7 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -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 ( <> - + -
diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields.tsx index 505a2f7c..1f0dafc2 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields.tsx @@ -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({ )} + {onAvetaRequiredChange != null && ( +
+ +
+ )}
Notes / Issue Detail