diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ab2cfb56..cde0f414 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -41,3 +41,25 @@ jobs: cache: npm - run: npm ci - run: npm run verify + + vendor-visual-regression: + runs-on: ubuntu-latest + container: mcr.microsoft.com/playwright:v1.61.1-noble + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + - run: npm ci + - run: npm run test:e2e:visual + - name: Upload visual diff artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: vendor-visual-regression-diffs + path: | + test-results/visual + playwright-report-visual + if-no-files-found: ignore + retention-days: 14 diff --git a/.gitignore b/.gitignore index 5b09a167..aabcecf0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ /coverage /test-results /playwright-report +/playwright-report-visual /blob-report /playwright/.cache diff --git a/.prettierignore b/.prettierignore index 27f10fea..fddfe3d3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,6 +2,7 @@ dist build node_modules coverage +playwright-report-visual package-lock.json *.tsbuildinfo @@ -10,4 +11,4 @@ src/pages/** src/**/*.js src/**/*.jsx src/**/*.css -!src/styles/globals.css \ No newline at end of file +!src/styles/globals.css diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png new file mode 100644 index 00000000..6e091c72 Binary files /dev/null and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png new file mode 100644 index 00000000..1beb0a15 Binary files /dev/null and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png new file mode 100644 index 00000000..a80af4db Binary files /dev/null and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png new file mode 100644 index 00000000..6c1ca3b4 Binary files /dev/null and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png new file mode 100644 index 00000000..f5948c0d Binary files /dev/null and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png new file mode 100644 index 00000000..a1509c39 Binary files /dev/null and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png new file mode 100644 index 00000000..c3e9be5b Binary files /dev/null and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png new file mode 100644 index 00000000..15eea83d Binary files /dev/null and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png new file mode 100644 index 00000000..901b2ab5 Binary files /dev/null and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png new file mode 100644 index 00000000..a2eeb471 Binary files /dev/null and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png differ diff --git a/e2e/vendor-portal/portal-lifecycle.spec.ts b/e2e/vendor-portal/portal-lifecycle.spec.ts new file mode 100644 index 00000000..294051a6 --- /dev/null +++ b/e2e/vendor-portal/portal-lifecycle.spec.ts @@ -0,0 +1,221 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const PORTAL_TOKEN = "portal-e2e-token"; + +const futureDate = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(); +const pastDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); + +interface DispatchDetail { + id: number; + dispatchNumber: string; + workOrderTitle?: string; + internalWONumber?: string; + status: string; + nteAmount?: number; + scheduledDate?: string; + poNumber?: string; + dispatchedAt?: string; + completedDate?: string; + refusedAt?: string; + statusUpdatedAt?: string; + description?: string; + workOrder?: Record; + location?: Record; + dispatcherContact?: { name?: string; email?: string; phone?: string }; + checklist: never[]; + comments: never[]; + upliftRequests: never[]; + signoffs: never[]; + documents: never[]; +} + +const sentDetail: DispatchDetail = { + id: 101, + dispatchNumber: "DSP-101", + workOrderTitle: "Emergency boiler repair", + internalWONumber: "WO-8842", + status: "Sent", + nteAmount: 1250, + scheduledDate: futureDate, + poNumber: "PO-555", + statusUpdatedAt: "2026-08-09T16:30:00Z", + description: "Boiler is leaking; vendor visit required.", + workOrder: { + workerOrderTitle: "Emergency boiler repair", + internalWONumber: "WO-8842", + trade: "Plumbing", + subTrade: "Backflow Preventers", + siteCode: "STL-01", + service: "Boiler Repair", + }, + location: { + name: "Central Distribution Center", + address: "100 Industrial Pkwy", + city: "St. Louis", + state: "MO", + zipCode: "63101", + }, + dispatcherContact: { + name: "Dana Field", + email: "dana@seahavenind.com", + phone: "314-555-0142", + }, + checklist: [], + comments: [], + upliftRequests: [], + signoffs: [], + documents: [], +}; + +const listDispatches = [ + { + id: 101, + dispatchNumber: "DSP-101", + workOrderTitle: "Emergency boiler repair", + internalWONumber: "WO-8842", + locationName: "Central Distribution Center", + locationCity: "St. Louis", + locationState: "MO", + status: "Sent", + nteAmount: 1250, + scheduledDate: futureDate, + }, + { + id: 102, + dispatchNumber: "DSP-102", + workOrderTitle: "HVAC quarterly service", + status: "Refused", + refusedAt: "2026-08-08T10:00:00Z", + }, + { + id: 103, + dispatchNumber: "DSP-103", + workOrderTitle: "PastAcknowledged job", + status: "Acknowledged", + scheduledDate: pastDate, + }, +]; + +async function fulfill(route: Route, body: unknown, status = 200) { + await route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(body), + }); +} + +async function mockPortalApi(page: Page, options: { sessionError?: boolean } = {}) { + const detailState: { current: DispatchDetail } = { current: { ...sentDetail } }; + + await page.route("**/api/vendor-portal/session", (route) => { + if (options.sessionError) { + fulfill(route, { message: "Invalid or expired token" }, 401); + return; + } + fulfill(route, { data: { companyName: "Gateway Plumbing", vendorId: 101 } }); + }); + + await page.route(/\/api\/vendor-portal\/dispatches\/\d+\/refuse$/, async (route) => { + const body = route.request().postDataJSON() as { reason?: string }; + detailState.current = { + ...detailState.current, + status: "Refused", + refusedAt: new Date().toISOString(), + }; + fulfill(route, { + data: { + id: detailState.current.id, + status: "Refused", + refusedAt: detailState.current.refusedAt, + reason: body?.reason, + }, + }); + }); + + await page.route(/\/api\/vendor-portal\/dispatches\/\d+$/, (route) => + fulfill(route, { data: detailState.current }), + ); + + await page.route(/\/api\/vendor-portal\/dispatches(?:\?.*)?$/, (route) => + fulfill(route, { data: listDispatches }), + ); + + return detailState; +} + +test.describe("Vendor portal lifecycle", () => { + test("upcoming filter, detail snapshot, refuse dialog, and locked Refused state", async ({ + page, + }) => { + await mockPortalApi(page); + await page.goto(`/v/${PORTAL_TOKEN}/dashboard`); + + await expect(page.getByRole("heading", { name: "Your Work" })).toBeVisible(); + + await expect(page.getByRole("button", { name: /^Upcoming/ })).toBeVisible(); + await expect(page.getByRole("button", { name: /^Refused/ })).toBeVisible(); + + await page.getByRole("button", { name: /^Upcoming/ }).click(); + await expect(page.getByText("DSP-101 — Emergency boiler repair")).toBeVisible(); + await expect(page.getByText("DSP-103")).toHaveCount(0); + + await page.getByText("DSP-101 — Emergency boiler repair").click(); + await expect(page).toHaveURL(/\/dispatch\/101$/); + + await expect(page.getByRole("heading", { name: "DSP-101" })).toBeVisible(); + await expect(page.getByText("Boiler Repair", { exact: true })).toBeVisible(); + await expect(page.getByText("STL-01")).toBeVisible(); + await expect(page.getByText("$1250.00").first()).toBeVisible(); + await expect(page.getByRole("link", { name: "dana@seahavenind.com" })).toBeVisible(); + await expect(page.getByText("Dana Field")).toBeVisible(); + await expect(page.getByText("314-555-0142")).toBeVisible(); + await expect(page.getByText(/Status updated/i)).toBeVisible(); + + await page.getByRole("button", { name: "Refuse Dispatch" }).click(); + const dialog = page.getByRole("dialog", { name: "Refuse dispatch" }); + await expect(dialog).toBeVisible(); + await dialog.getByLabel(/Reason/i).fill("Equipment unavailable this week."); + await expect(dialog.getByText(/\d+\/500/)).toBeVisible(); + await dialog.getByRole("button", { name: "Refuse dispatch" }).click(); + + await expect(dialog).toHaveCount(0); + await expect(page.getByText("Refused", { exact: true })).toBeVisible(); + await expect(page.getByText(/refused and locked/i)).toBeVisible(); + await expect(page.getByRole("button", { name: "Accept Dispatch" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Refuse Dispatch" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Submit Signoff" })).toHaveCount(0); + }); + + test("covers the refuse dialog at a mobile viewport", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockPortalApi(page); + await page.goto(`/v/${PORTAL_TOKEN}/dashboard`); + + await page.getByText("DSP-101 — Emergency boiler repair").click(); + await expect(page).toHaveURL(/\/dispatch\/101$/); + + await page.getByRole("button", { name: "Refuse Dispatch" }).click(); + const dialog = page.getByRole("dialog", { name: "Refuse dispatch" }); + await expect(dialog).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + + await page.getByRole("button", { name: "Refuse Dispatch" }).click(); + await page + .getByRole("dialog", { name: "Refuse dispatch" }) + .getByRole("button", { + name: "Refuse dispatch", + }) + .click(); + await expect(page.getByText("Refused", { exact: true })).toBeVisible(); + await expect(page.getByText(/refused and locked/i)).toBeVisible(); + }); + + test("shows an expired-token state when the session is rejected", async ({ page }) => { + await mockPortalApi(page, { sessionError: true }); + await page.goto(`/v/${PORTAL_TOKEN}/dashboard`); + + await expect(page.getByRole("heading", { name: "Access Denied" })).toBeVisible(); + await expect(page.getByRole("alert")).toContainText(/invalid or expired/i); + }); +}); diff --git a/package-lock.json b/package-lock.json index c732e572..426dc13b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4048,9 +4048,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -4578,9 +4578,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -5302,9 +5302,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -5561,9 +5561,9 @@ } }, "node_modules/postcss": { - "version": "8.5.18", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz", - "integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -5580,7 +5580,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/package.json b/package.json index 3df2f4da..10e69a08 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", + "test:e2e:visual": "playwright test --config playwright.visual.config.ts", "test:e2e:ui": "playwright test --ui", "lint": "eslint . --max-warnings=0", "lint:fix": "eslint . --fix --max-warnings=0", diff --git a/playwright.config.ts b/playwright.config.ts index 7360b05c..c37b71ed 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,6 +2,7 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./e2e", + testIgnore: "**/*.visual.spec.ts", fullyParallel: true, forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 2 : 0, diff --git a/playwright.visual.config.ts b/playwright.visual.config.ts new file mode 100644 index 00000000..f442c886 --- /dev/null +++ b/playwright.visual.config.ts @@ -0,0 +1,43 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + testMatch: "**/*.visual.spec.ts", + fullyParallel: false, + forbidOnly: true, + retries: 0, + workers: 1, + reporter: [["list"], ["html", { outputFolder: "playwright-report-visual", open: "never" }]], + outputDir: "test-results/visual", + snapshotPathTemplate: "{testDir}/__screenshots__/{testFilePath}/{arg}{ext}", + expect: { + toHaveScreenshot: { + animations: "disabled", + caret: "hide", + // Keep the changed-pixel budget strict while ignoring sub-perceptual + // antialiasing differences between local and hosted container runtimes. + maxDiffPixels: 5, + threshold: 0.1, + }, + }, + use: { + ...devices["Desktop Chrome"], + baseURL: "http://localhost:4174", + colorScheme: "light", + deviceScaleFactor: 1, + locale: "en-US", + launchOptions: { + args: ["--disable-gpu", "--font-render-hinting=none"], + }, + reducedMotion: "reduce", + timezoneId: "UTC", + trace: "retain-on-failure", + viewport: { width: 1440, height: 1000 }, + }, + webServer: { + command: "npm run dev -- --port 4174 --strictPort", + url: "http://localhost:4174", + reuseExistingServer: false, + timeout: 120_000, + }, +}); diff --git a/src/app/(protected)/_layout.tsx b/src/app/(protected)/_layout.tsx index c3281edf..eca5363c 100644 --- a/src/app/(protected)/_layout.tsx +++ b/src/app/(protected)/_layout.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { Outlet } from "react-router"; -import { Box } from "@mui/material"; +import { Box, useMediaQuery, useTheme } from "@mui/material"; import { ProtectedRoute } from "@/components/auth/protected-route"; import { AppSidebar } from "@/components/layout/app-sidebar"; @@ -8,19 +8,36 @@ import { getSidebarWidth } from "@/config/menu"; import { AppTopbar } from "@/components/layout/app-topbar"; export default function AdminShellLayout() { + const theme = useTheme(); + const isDesktop = useMediaQuery(theme.breakpoints.up("md")); const [navCollapsed, setNavCollapsed] = useState(false); - const sidebarWidth = getSidebarWidth(navCollapsed); + const [mobileNavOpen, setMobileNavOpen] = useState(false); + const sidebarWidth = isDesktop ? getSidebarWidth(navCollapsed) : 0; + + const handleToggleNav = () => { + if (isDesktop) { + setNavCollapsed((value) => !value); + return; + } + + setMobileNavOpen((value) => !value); + }; return ( - + setMobileNavOpen(false)} + /> - setNavCollapsed((value) => !value)} /> + void; + value: string; + onSave: (iso: string) => void; +}; + +/** + * Emergency Due Date picker — dedicated calendar Dialog (no time, no week/range). + * Reuses the shared MiniCalendar used by the standard Due Date popover. + */ +export function DueDateCalendarDialog({ + open, + onOpenChange, + value, + onSave, +}: DueDateCalendarDialogProps) { + return ( + onOpenChange(false)} + maxWidth={false} + slotProps={{ + ...dialogBlurBackdropSlotProps, + paper: { + className: "w-full", + style: { borderRadius: 12, maxWidth: 320 }, + onClick: (e: MouseEvent) => e.stopPropagation(), + }, + }} + > + e.stopPropagation()}> + + Due Date + +
+ { + onSave(iso); + onOpenChange(false); + }} + /> +
+
+
+ ); +} 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 new file mode 100644 index 00000000..d6cae8fb --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import { Calendar as CalendarIcon, ChevronDown } from "lucide-react"; +import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell"; +import { DueDateCalendarDialog } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog"; +import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; + +type DueDateCellProps = { + type: WOType | ""; + value: string; + q?: string; + highlight?: boolean; + onSave: (date: string) => void; +}; + +/** + * Board / slide-over Due Date editor. + * Emergency → dedicated calendar Dialog; other types → standard DateCell popover. + */ +export function DueDateCell({ type, value, q, highlight, onSave }: DueDateCellProps) { + const [open, setOpen] = useState(false); + + if (type !== "Emergency") { + return ; + } + + return ( + <> + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx index 733d17d0..d2e4c78a 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx @@ -49,7 +49,7 @@ export function StatusCell({ setAnchorEl(e.currentTarget); }} - title="This WO missed its service window. Reschedule to unlock status updates." + title="This WO missed its deadline. Update the Due Date to unlock status updates." className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80" style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }} > @@ -83,8 +83,7 @@ export function StatusCell({ color: "var(--status-pending-text)", }} > - This work order is past due. A new date must be scheduled before the status can be - updated. + This work order is past due. Update the Due Date before the status can be updated. diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx index a9052764..a3056b2e 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx @@ -7,6 +7,7 @@ import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order- import { AssignedToCell } from "./cells/assigned-to-cell"; import { ApptTimeCell } from "./cells/appt-time-cell"; import { DateCell } from "./cells/date-cell"; +import { DueDateCell } from "./cells/due-date-cell"; import { EditableWONumberCell } from "./cells/editable-wo-number-cell"; import { PMTypeCell } from "./cells/pm-type-cell"; import { SiteCell } from "./cells/site-cell"; @@ -106,7 +107,11 @@ export function WoTableInlineRowCells({ - onPatch({ dueDate: date })} /> + onPatch({ dueDate: date })} + /> diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx index 59de1a9f..40f97933 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx @@ -3,6 +3,7 @@ import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/w import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { AssignedToCell } from "./cells/assigned-to-cell"; import { DateCell } from "./cells/date-cell"; +import { DueDateCell } from "./cells/due-date-cell"; import { EditableWONumberCell } from "./cells/editable-wo-number-cell"; import { SiteCell } from "./cells/site-cell"; import { TypeCell } from "./cells/type-cell"; @@ -16,6 +17,7 @@ type WoTableRowCellsProps = { sitePlace?: { city: string; state: string }; search?: string; forceScheduleHighlight?: boolean; + forceDueDateHighlight?: boolean; mutationCellStyle?: CSSProperties; handlers: WoTableRowHandlers; onPatchRow: (patch: Partial) => void; @@ -29,6 +31,7 @@ export function WoTableRowCells({ sitePlace, search, forceScheduleHighlight, + forceDueDateHighlight, mutationCellStyle, handlers, onPatchRow, @@ -104,7 +107,7 @@ export function WoTableRowCells({ handlers.onScheduleDate(row.id, date)} @@ -116,7 +119,13 @@ export function WoTableRowCells({ style={{ padding: "0 14px", ...mutationCellStyle }} onClick={(e) => e.stopPropagation()} > - onPatchRow({ dueDate: date })} /> + onPatchRow({ dueDate: date })} + /> ); diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx index b3ba685f..e6a22420 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx @@ -45,6 +45,7 @@ type WoTableRowProps = { pmTypes: string[]; search?: string; forceScheduleHighlight?: boolean; + forceDueDateHighlight?: boolean; isSelected: boolean; isFlashing: boolean; dragViz: DragVizState; @@ -61,6 +62,7 @@ export function WoTableRow({ pmTypes, search, forceScheduleHighlight, + forceDueDateHighlight, isSelected, isFlashing, dragViz, @@ -154,6 +156,7 @@ export function WoTableRow({ sitePlace={sitePlace} search={search} forceScheduleHighlight={forceScheduleHighlight} + forceDueDateHighlight={forceDueDateHighlight} mutationCellStyle={mutationCellStyle} handlers={handlers} onPatchRow={patchRow} 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 e94c3ad7..94e019a7 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -54,7 +54,7 @@ export function WoTable({ onOpenAdvancedFilters, onPatch, }: WoTableProps) { - const [scheduleHighlightId, setScheduleHighlightId] = useState(null); + const [dueDateHighlightId, setDueDateHighlightId] = useState(null); const { data: locationSites = [] } = useLocationSites(); const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]); const createMutation = useCreateWorkOrderFromWizard(() => { @@ -69,7 +69,7 @@ export function WoTable({ onOpenCompDoc, onOpenMedia, onPatch, - onScheduleHighlight: setScheduleHighlightId, + onScheduleHighlight: setDueDateHighlightId, }); const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => ( @@ -83,7 +83,8 @@ export function WoTable({ vendors={vendors} pmTypes={pmTypes} search={tableData.search} - forceScheduleHighlight={forceScheduleHighlight || scheduleHighlightId === row.id} + forceScheduleHighlight={forceScheduleHighlight} + forceDueDateHighlight={dueDateHighlightId === row.id} isSelected={tableData.selectedId === row.id} isFlashing={tableData.flashId === row.id} dragViz={tableData.dragViz} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-due-date-field.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-due-date-field.tsx new file mode 100644 index 00000000..7ec4888e --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/wizard-due-date-field.tsx @@ -0,0 +1,69 @@ +import { useState } from "react"; +import { CalendarIcon, ChevronDown } from "lucide-react"; +import { DueDateCalendarDialog } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog"; +import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; +import { fmtDate } from "@/domain/work-orders/utils/wizard-date-utils"; + +type WizardDueDateFieldProps = { + type: WOType | ""; + value: string; + placeholder: string; + onChange: (iso: string) => void; +}; + +/** + * Wizard Due Date editor. + * Emergency → dedicated calendar Dialog; other types → WizardDateField popover. + */ +export function WizardDueDateField({ + type, + value, + placeholder, + onChange, +}: WizardDueDateFieldProps) { + const [open, setOpen] = useState(false); + + if (type !== "Emergency") { + return ; + } + + return ( + <> + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx index e3d4de1e..51c6f0d3 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx @@ -3,6 +3,7 @@ import { Text } from "@/components/ui/text"; import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; import { WO_TYPE_META, WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard"; import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field"; +import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field"; import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; import { DLG_INPUT_CLS, @@ -197,7 +198,8 @@ export function WizardStepTypeSchedule({ > Due Date - onUpdate("dueDate", iso)} diff --git a/src/app/v/[token]/dashboard.tsx b/src/app/v/[token]/dashboard.tsx index eeb222a2..65b87bff 100644 --- a/src/app/v/[token]/dashboard.tsx +++ b/src/app/v/[token]/dashboard.tsx @@ -8,36 +8,18 @@ import { formatVendorPortalDateTime, vendorPortalStatusClass, } from "@/domain/vendor-portal/lib/status-helpers"; -import type { VendorPortalDispatchSummary } from "@/domain/vendor-portal/types/vendor-portal"; +import { + VENDOR_PORTAL_DISPATCH_FILTERS, + applyVendorPortalDispatchFilter, + getVendorPortalDispatchFilter, + type VendorPortalDispatchFilterKey, +} from "@/domain/vendor-portal/lib/dispatch-filters"; import { queryKeys } from "@/infra/query-key/query-key"; -const FILTERS = [ - { - key: "open", - label: "Open", - match: (dispatch: VendorPortalDispatchSummary) => - dispatch.status !== "Completed" && - dispatch.status !== "Verified" && - dispatch.status !== "Cancelled", - }, - { - key: "completed", - label: "Completed", - match: (dispatch: VendorPortalDispatchSummary) => - dispatch.status === "Completed" || dispatch.status === "Verified", - }, - { - key: "cancelled", - label: "Cancelled", - match: (dispatch: VendorPortalDispatchSummary) => dispatch.status === "Cancelled", - }, - { key: "all", label: "All", match: () => true }, -] as const; - export default function VendorPortalDashboardPage() { const { token } = useVendorPortal(); const navigate = useNavigate(); - const [filter, setFilter] = useState<(typeof FILTERS)[number]["key"]>("open"); + const [filter, setFilter] = useState("open"); const { data: dispatches = [], @@ -49,8 +31,11 @@ export default function VendorPortalDashboardPage() { enabled: Boolean(token), }); - const activeFilter = FILTERS.find((item) => item.key === filter) ?? FILTERS[0]; - const filtered = useMemo(() => dispatches.filter(activeFilter.match), [dispatches, activeFilter]); + const activeFilter = getVendorPortalDispatchFilter(filter); + const filtered = useMemo( + () => applyVendorPortalDispatchFilter(dispatches, activeFilter), + [dispatches, activeFilter], + ); return ( <> @@ -60,7 +45,7 @@ export default function VendorPortalDashboardPage() { Active dispatches and work orders assigned to your company.
- {FILTERS.map((item) => ( + {VENDOR_PORTAL_DISPATCH_FILTERS.map((item) => (
@@ -88,6 +89,7 @@ export function DispatchDetailSections({ existing={data.signoffs ?? []} signoffType="vendor" nameLabel="Your name" + submissionLocked={locked} onAdded={() => void refresh()} /> diff --git a/src/app/v/[token]/dispatch/_components/dispatch-snapshot-details.tsx b/src/app/v/[token]/dispatch/_components/dispatch-snapshot-details.tsx new file mode 100644 index 00000000..4f71d6d8 --- /dev/null +++ b/src/app/v/[token]/dispatch/_components/dispatch-snapshot-details.tsx @@ -0,0 +1,166 @@ +import { Text } from "@/components/ui/text"; +import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers"; +import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal"; + +type DispatchSnapshotDetailsProps = { + data: VendorPortalDispatchDetail; + locked: boolean; +}; + +function resolveCanonicalService(workOrder: VendorPortalDispatchDetail["workOrder"]): string { + if (workOrder?.service) { + return workOrder.service; + } + + return [workOrder?.trade, workOrder?.subTrade].filter(Boolean).join(" · "); +} + +function DispatchCommercialDetails({ data }: Pick) { + const service = resolveCanonicalService(data.workOrder); + + return ( +
+ {data.workOrder != null && Boolean(data.workOrder.internalWONumber) && ( +
+ WO # + {data.workOrder.internalWONumber} +
+ )} + {Boolean(service) && ( +
+ Service + {service} +
+ )} + {Boolean(data.poNumber) && ( +
+ PO # + {data.poNumber} +
+ )} + {data.nteAmount != null && ( +
+ NTE + ${Number(data.nteAmount).toFixed(2)} +
+ )} +
+ Invoice + + {data.invoiceStatus || "Not Submitted"} + {data.invoiceNumber ? ` · ${data.invoiceNumber}` : ""} + +
+
+ Payment + {data.paymentStatus || "Unavailable"} +
+ {Boolean(data.scheduledDate) && ( +
+ Scheduled + {formatVendorPortalDateTime(data.scheduledDate)} +
+ )} + {Boolean(data.statusUpdatedAt) && ( +
+ Status updated + {formatVendorPortalDateTime(data.statusUpdatedAt)} +
+ )} +
+ ); +} + +function DispatchLocationDetails({ data }: Pick) { + if (data.location == null) { + return null; + } + + const address = [ + data.location.address, + data.location.city, + data.location.state, + data.location.zipCode, + ] + .filter(Boolean) + .join(", "); + + return ( + <> + + Location + + {Boolean(data.workOrder?.siteCode) && ( +
+ Site code + {data.workOrder?.siteCode} +
+ )} +
{data.location.name}
+
{address}
+ + ); +} + +function DispatcherContactDetails({ data }: Pick) { + const contact = data.dispatcherContact; + if (!contact || (!contact.name && !contact.email && !contact.phone)) { + return null; + } + + return ( + <> + + Dispatcher + + {Boolean(contact.name) && ( +
+ Name + {contact.name} +
+ )} + {Boolean(contact.email) && ( +
+ Email + + {contact.email} + +
+ )} + {Boolean(contact.phone) && ( +
+ Phone + + {contact.phone} + +
+ )} + + ); +} + +export function DispatchSnapshotDetails({ data, locked }: DispatchSnapshotDetailsProps) { + const description = data.description || data.workOrder?.description; + + return ( + <> + + + + {locked && ( + + This dispatch is {data.status.toLowerCase()} and locked to further changes. Comments + remain open. + + )} + {Boolean(description) && ( + <> + + Description + +
{description}
+ + )} + + ); +} diff --git a/src/app/v/[token]/dispatch/_components/dispatch-status-actions.tsx b/src/app/v/[token]/dispatch/_components/dispatch-status-actions.tsx index 7dc3bcea..07d93c92 100644 --- a/src/app/v/[token]/dispatch/_components/dispatch-status-actions.tsx +++ b/src/app/v/[token]/dispatch/_components/dispatch-status-actions.tsx @@ -3,6 +3,7 @@ type DispatchStatusActionsProps = { locked: boolean; busy: boolean; onAccept: () => void; + onRefuse: () => void; onStart: () => void; onComplete: () => void; onRequestCancel: () => void; @@ -13,6 +14,7 @@ export function DispatchStatusActions({ locked, busy, onAccept, + onRefuse, onStart, onComplete, onRequestCancel, @@ -20,9 +22,19 @@ export function DispatchStatusActions({ return (
{status === "Sent" && ( - + <> + + + )} {status === "Acknowledged" && (
-
- {data.workOrder != null && Boolean(data.workOrder.internalWONumber) && ( -
- WO # - {data.workOrder.internalWONumber} -
- )} - {Boolean(data.poNumber) && ( -
- PO # - {data.poNumber} -
- )} - {data.nteAmount != null && ( -
- NTE - ${Number(data.nteAmount).toFixed(2)} -
- )} -
- Invoice - - {data.invoiceStatus || "Not Submitted"} - {data.invoiceNumber ? ` · ${data.invoiceNumber}` : ""} - -
-
- Payment - {data.paymentStatus || "Unavailable"} -
- {Boolean(data.scheduledDate) && ( -
- Scheduled - {formatVendorPortalDateTime(data.scheduledDate)} -
- )} -
- - {data.location != null && ( - <> - - Location - -
{data.location.name}
-
- {[ - data.location.address, - data.location.city, - data.location.state, - data.location.zipCode, - ] - .filter(Boolean) - .join(", ")} -
- - )} - - {Boolean(data.description || data.workOrder?.description) && ( - <> - - Description - -
{data.description || data.workOrder?.description}
- - )} + void; + onRefused: () => void; +}; + +export function RefuseDispatchDialog({ + open, + dispatchId, + onClose, + onRefused, +}: RefuseDispatchDialogProps) { + const { token } = useVendorPortal(); + const queryClient = useQueryClient(); + + const { + register, + handleSubmit, + control, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(refuseDispatchSchema), + mode: "onChange", + defaultValues: { reason: "" }, + }); + + const reasonValue = useWatch({ control, name: "reason" }) ?? ""; + const isOverLimit = reasonValue.length > REFUSE_REASON_MAX_LENGTH; + + const refuseMutation = useMutation({ + mutationFn: (values: RefuseDispatchValues) => { + const trimmed = values.reason.trim(); + return vendorPortalApi.refuse(token, dispatchId, trimmed || undefined); + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.vendorPortal.dispatch(token, dispatchId), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.vendorPortal.dispatches(token), + }), + ]); + onRefused(); + onClose(); + }, + }); + + const { reset: resetMutation } = refuseMutation; + + useEffect(() => { + if (!open) { + reset({ reason: "" }); + resetMutation(); + } + }, [open, reset, resetMutation]); + + const submit = handleSubmit((values) => refuseMutation.mutate(values)); + const errorMessage = refuseMutation.error + ? "Unable to refuse this dispatch. Please try again." + : ""; + + return ( + + Refuse dispatch +
void submit(event)}> + + + Refusing ends your assignment for this dispatch. A reason is optional but helps your + dispatcher reassign the work. + + +