fix(vendors): complete shell and visual parity gates
22
.github/workflows/ci.yaml
vendored
|
|
@ -41,3 +41,25 @@ jobs:
|
||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run verify
|
- 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
|
||||||
|
|
|
||||||
1
.gitignore
vendored
|
|
@ -10,6 +10,7 @@
|
||||||
/coverage
|
/coverage
|
||||||
/test-results
|
/test-results
|
||||||
/playwright-report
|
/playwright-report
|
||||||
|
/playwright-report-visual
|
||||||
/blob-report
|
/blob-report
|
||||||
/playwright/.cache
|
/playwright/.cache
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ dist
|
||||||
build
|
build
|
||||||
node_modules
|
node_modules
|
||||||
coverage
|
coverage
|
||||||
|
playwright-report-visual
|
||||||
package-lock.json
|
package-lock.json
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
|
|
||||||
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png
vendored
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png
vendored
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png
vendored
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png
vendored
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png
vendored
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png
vendored
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png
vendored
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png
vendored
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png
vendored
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png
vendored
Normal file
|
After Width: | Height: | Size: 46 KiB |
33
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -556,6 +556,20 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
await mockVendorApi(page, { listError: true });
|
await mockVendorApi(page, { listError: true });
|
||||||
await page.goto("/vendors");
|
await page.goto("/vendors");
|
||||||
|
|
||||||
|
const mainBox = await page.getByRole("main").boundingBox();
|
||||||
|
expect(mainBox).toMatchObject({ x: 0, width: 390 });
|
||||||
|
const navigation = page.getByRole("navigation", { name: "Primary navigation" });
|
||||||
|
await expect(navigation).not.toBeVisible();
|
||||||
|
|
||||||
|
const openNavigation = page.getByRole("button", { name: "Toggle navigation" });
|
||||||
|
await openNavigation.focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(navigation).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "Sites" })).toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(navigation).not.toBeVisible();
|
||||||
|
await expect(openNavigation).toBeFocused();
|
||||||
|
|
||||||
await expect(page.getByRole("main").getByRole("alert")).toContainText(
|
await expect(page.getByRole("main").getByRole("alert")).toContainText(
|
||||||
/Vendor directory unavailable|failed|server error/i,
|
/Vendor directory unavailable|failed|server error/i,
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
|
|
@ -569,4 +583,23 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png");
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png");
|
||||||
await page.getByRole("button", { name: "Close filters" }).click();
|
await page.getByRole("button", { name: "Close filters" }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps protected shell destinations available and marks their selected state", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockVendorApi(page);
|
||||||
|
|
||||||
|
for (const destination of [
|
||||||
|
{ path: "/workorders", label: "Work Orders" },
|
||||||
|
{ path: "/accounts", label: "Accounts" },
|
||||||
|
{ path: "/locations", label: "Sites" },
|
||||||
|
{ path: "/notifications", label: "Notifications" },
|
||||||
|
]) {
|
||||||
|
await page.goto(destination.path);
|
||||||
|
await expect(page.getByRole("link", { name: destination.label })).toHaveAttribute(
|
||||||
|
"aria-current",
|
||||||
|
"page",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
261
e2e/vendors/vendors.visual.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||||
|
|
||||||
|
const TEST_USER = {
|
||||||
|
token: "vendor-visual-token",
|
||||||
|
expiration: "2030-01-01T00:00:00.000Z",
|
||||||
|
email: "admin@seahavenind.com",
|
||||||
|
userRoles: "Admin",
|
||||||
|
phoneNumber: "5551234567",
|
||||||
|
fullname: "Vendor Visual Admin",
|
||||||
|
id: "1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeVendors = [
|
||||||
|
{
|
||||||
|
Id: 1,
|
||||||
|
CompanyId: 101,
|
||||||
|
CompanyName: "Gateway Plumbing",
|
||||||
|
ContactName: "Adam Whyte",
|
||||||
|
Email: "dispatch@gateway.test",
|
||||||
|
Phone: "314-555-0198",
|
||||||
|
CompanyPhone: "314-555-0100",
|
||||||
|
Address: "1 Market St",
|
||||||
|
City: "St. Louis",
|
||||||
|
State: "MO",
|
||||||
|
Zip: "63101",
|
||||||
|
TradeSpecialties: "Plumbing, Backflow Preventers",
|
||||||
|
GoogleMapsUrl: "https://maps.google.com/gateway",
|
||||||
|
Notes: "Preferred after-hours vendor",
|
||||||
|
TotalJobs: 72,
|
||||||
|
IsActive: true,
|
||||||
|
PreferredContact: "Email",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: 2,
|
||||||
|
CompanyId: 102,
|
||||||
|
CompanyName: "Metro HVAC",
|
||||||
|
ContactName: "Maya Chen",
|
||||||
|
Email: "maya@metro.test",
|
||||||
|
Phone: "314-555-0112",
|
||||||
|
CompanyPhone: "314-555-0110",
|
||||||
|
Address: "2 Pine St",
|
||||||
|
City: "Clayton",
|
||||||
|
State: "MO",
|
||||||
|
Zip: "63105",
|
||||||
|
TradeSpecialties: "HVAC",
|
||||||
|
GoogleMapsUrl: "",
|
||||||
|
Notes: "",
|
||||||
|
TotalJobs: 18,
|
||||||
|
IsActive: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const inactiveVendors = [
|
||||||
|
{
|
||||||
|
...activeVendors[0],
|
||||||
|
Id: 3,
|
||||||
|
CompanyId: 103,
|
||||||
|
CompanyName: "Drain Cleaning Bros",
|
||||||
|
ContactName: "Drew Banks",
|
||||||
|
TotalJobs: 151,
|
||||||
|
IsActive: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||||
|
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockVendorApi(page: Page, mode: "default" | "empty" | "error" = "default") {
|
||||||
|
await page.clock.setFixedTime(new Date("2026-08-10T12:00:00.000Z"));
|
||||||
|
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||||
|
|
||||||
|
await page.route("**/api/**", async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const url = new URL(request.url());
|
||||||
|
|
||||||
|
if (!url.pathname.startsWith("/api/")) {
|
||||||
|
await route.continue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.includes("/DropdownOptions/ByCategory/Trade")) {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
data: [
|
||||||
|
{ id: 1, value: "Plumbing" },
|
||||||
|
{ id: 2, value: "HVAC" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.endsWith("/api/Vendor/facets")) {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
companies: activeVendors.map((vendor) => ({
|
||||||
|
companyId: vendor.CompanyId,
|
||||||
|
name: vendor.CompanyName,
|
||||||
|
})),
|
||||||
|
trades: ["Plumbing", "Backflow Preventers", "HVAC"],
|
||||||
|
locations: [
|
||||||
|
{ city: "St. Louis", state: "MO", label: "St. Louis, MO" },
|
||||||
|
{ city: "Clayton", state: "MO", label: "Clayton, MO" },
|
||||||
|
],
|
||||||
|
jobBuckets: [
|
||||||
|
{ id: "under-50", label: "Under 50" },
|
||||||
|
{ id: "50-99", label: "50–99" },
|
||||||
|
{ id: "100-149", label: "100–149" },
|
||||||
|
{ id: "150-plus", label: "150+" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.endsWith("/api/Vendor/GetVendorList")) {
|
||||||
|
const isCount = url.searchParams.get("pageSize") === "1";
|
||||||
|
if (mode === "error" && !isCount) {
|
||||||
|
await fulfillJson(route, { message: "Vendor directory unavailable" }, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const source =
|
||||||
|
mode === "empty"
|
||||||
|
? []
|
||||||
|
: url.searchParams.get("isActive") === "false"
|
||||||
|
? inactiveVendors
|
||||||
|
: activeVendors;
|
||||||
|
await fulfillJson(route, {
|
||||||
|
Data: isCount ? source.slice(0, 1) : source,
|
||||||
|
TotalCount: source.length,
|
||||||
|
PageNumber: 1,
|
||||||
|
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith("/api/vendor-company-roster")) {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
companyId: 101,
|
||||||
|
rowVersion: "rv-visual",
|
||||||
|
name: "Gateway Plumbing",
|
||||||
|
companyPhone: "314-555-0100",
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
address: "1 Market St",
|
||||||
|
city: "St. Louis",
|
||||||
|
state: "MO",
|
||||||
|
zip: "63101",
|
||||||
|
googleMapsUrl: "https://maps.google.com/gateway",
|
||||||
|
notes: "Preferred after-hours vendor",
|
||||||
|
technicians: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
contactName: "Adam Whyte",
|
||||||
|
phone: "314-555-0198",
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
preferredContact: "Email",
|
||||||
|
tradeSpecialties: "Plumbing, Backflow Preventers",
|
||||||
|
isActive: true,
|
||||||
|
totalJobs: 72,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fulfillJson(route, { data: {} });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openVendorPage(page: Page, mode: "default" | "empty" | "error" = "default") {
|
||||||
|
await mockVendorApi(page, mode);
|
||||||
|
await page.goto("/vendors");
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
await page.addStyleTag({
|
||||||
|
content: `
|
||||||
|
*, *::before, *::after { transition: none !important; animation: none !important; }
|
||||||
|
.MuiDrawer-paper { transform: none !important; }
|
||||||
|
.Toastify__toast-container { display: none !important; }
|
||||||
|
.tsqd-parent-container { display: none !important; }
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectStableScreenshot(page: Page, name: string) {
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) =>
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.screenshot({ animations: "disabled", caret: "hide" });
|
||||||
|
await expect(page).toHaveScreenshot(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Vendor deterministic pixel regression", () => {
|
||||||
|
test("list", async ({ page }) => {
|
||||||
|
await openVendorPage(page);
|
||||||
|
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
|
||||||
|
await expectStableScreenshot(page, "vendor-list.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filter", async ({ page }) => {
|
||||||
|
await openVendorPage(page);
|
||||||
|
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
|
||||||
|
await expectStableScreenshot(page, "vendor-filter.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add", async ({ page }) => {
|
||||||
|
await openVendorPage(page);
|
||||||
|
await page.getByRole("button", { name: "Add Vendor", exact: true }).click();
|
||||||
|
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
|
||||||
|
await expectStableScreenshot(page, "vendor-add.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detail", async ({ page }) => {
|
||||||
|
await openVendorPage(page);
|
||||||
|
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Close drawer" })).toBeVisible();
|
||||||
|
await expectStableScreenshot(page, "vendor-detail.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("edit", async ({ page }) => {
|
||||||
|
await openVendorPage(page);
|
||||||
|
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Save changes" })).toBeVisible();
|
||||||
|
await expectStableScreenshot(page, "vendor-edit.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("inactive", async ({ page }) => {
|
||||||
|
await openVendorPage(page);
|
||||||
|
await page.getByRole("tab", { name: "Inactive (1)" }).click();
|
||||||
|
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
|
||||||
|
await expectStableScreenshot(page, "vendor-inactive.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty", async ({ page }) => {
|
||||||
|
await openVendorPage(page, "empty");
|
||||||
|
await expect(page.getByText("No active technicians found")).toBeVisible();
|
||||||
|
await expectStableScreenshot(page, "vendor-empty.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("error", async ({ page }) => {
|
||||||
|
await openVendorPage(page, "error");
|
||||||
|
await expect(page.getByRole("main").getByRole("alert")).toContainText(
|
||||||
|
/server error|vendor directory unavailable/i,
|
||||||
|
);
|
||||||
|
await expectStableScreenshot(page, "vendor-error.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await openVendorPage(page);
|
||||||
|
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
|
||||||
|
await expectStableScreenshot(page, "vendor-mobile.png");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Toggle navigation" }).click();
|
||||||
|
await expect(page.getByRole("navigation", { name: "Primary navigation" })).toBeVisible();
|
||||||
|
await expectStableScreenshot(page, "vendor-mobile-navigation.png");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"lint": "eslint . --max-warnings=0",
|
"lint": "eslint . --max-warnings=0",
|
||||||
"lint:fix": "eslint . --fix --max-warnings=0",
|
"lint:fix": "eslint . --fix --max-warnings=0",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
|
testIgnore: "**/*.visual.spec.ts",
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: Boolean(process.env.CI),
|
forbidOnly: Boolean(process.env.CI),
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
|
|
||||||
43
playwright.visual.config.ts
Normal file
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Outlet } from "react-router";
|
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 { ProtectedRoute } from "@/components/auth/protected-route";
|
||||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||||
|
|
@ -8,19 +8,36 @@ import { getSidebarWidth } from "@/config/menu";
|
||||||
import { AppTopbar } from "@/components/layout/app-topbar";
|
import { AppTopbar } from "@/components/layout/app-topbar";
|
||||||
|
|
||||||
export default function AdminShellLayout() {
|
export default function AdminShellLayout() {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isDesktop = useMediaQuery(theme.breakpoints.up("md"));
|
||||||
const [navCollapsed, setNavCollapsed] = useState(false);
|
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 (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Box className="flex h-screen w-full overflow-hidden bg-background">
|
<Box className="flex h-screen w-full overflow-hidden bg-background">
|
||||||
<AppSidebar collapsed={navCollapsed} />
|
<AppSidebar
|
||||||
|
collapsed={isDesktop ? navCollapsed : false}
|
||||||
|
mobile={!isDesktop}
|
||||||
|
open={isDesktop || mobileNavOpen}
|
||||||
|
onClose={() => setMobileNavOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
|
className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
|
||||||
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
|
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
|
||||||
>
|
>
|
||||||
<AppTopbar onToggleNav={() => setNavCollapsed((value) => !value)} />
|
<AppTopbar onToggleNav={handleToggleNav} />
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
component="main"
|
component="main"
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ export function VendorsListToolbar({
|
||||||
onChange={onSearchChange}
|
onChange={onSearchChange}
|
||||||
placeholder="Search name, company, trade…"
|
placeholder="Search name, company, trade…"
|
||||||
aria-label="Search vendors"
|
aria-label="Search vendors"
|
||||||
|
fullWidth
|
||||||
|
className="w-full md:w-56"
|
||||||
inputClassName="h-8 py-0 text-[13px]"
|
inputClassName="h-8 py-0 text-[13px]"
|
||||||
/>
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
|
|
@ -49,7 +51,7 @@ export function VendorsListToolbar({
|
||||||
onClick={onOpenFilters}
|
onClick={onOpenFilters}
|
||||||
aria-label="Open vendor filters"
|
aria-label="Open vendor filters"
|
||||||
badge={appliedFilterCount}
|
badge={appliedFilterCount}
|
||||||
className="w-[88.609375px] text-[13px]"
|
className="min-w-[88.609375px] flex-1 text-[13px] md:w-[88.609375px] md:flex-none"
|
||||||
>
|
>
|
||||||
Filters
|
Filters
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
|
@ -57,7 +59,7 @@ export function VendorsListToolbar({
|
||||||
variant="primary"
|
variant="primary"
|
||||||
icon={<Plus size={16} />}
|
icon={<Plus size={16} />}
|
||||||
onClick={onAddVendor}
|
onClick={onAddVendor}
|
||||||
className="w-[115.546875px] border-0 px-3 text-[13px]"
|
className="min-w-[115.546875px] flex-1 border-0 px-3 text-[13px] md:w-[115.546875px] md:flex-none"
|
||||||
>
|
>
|
||||||
Add Vendor
|
Add Vendor
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,10 @@ function getNavItemAppearance(isActive: boolean, isHover: boolean) {
|
||||||
type NavItemProps = NavItemDef & {
|
type NavItemProps = NavItemDef & {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
onNavigate?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function NavItem({ label, icon: Icon, path, collapsed, active, badge }: NavItemProps) {
|
function NavItem({ label, icon: Icon, path, collapsed, active, badge, onNavigate }: NavItemProps) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const isHover = hovered && !active;
|
const isHover = hovered && !active;
|
||||||
const { itemColor, iconColor, itemBg } = getNavItemAppearance(active, isHover);
|
const { itemColor, iconColor, itemBg } = getNavItemAppearance(active, isHover);
|
||||||
|
|
@ -45,6 +46,7 @@ function NavItem({ label, icon: Icon, path, collapsed, active, badge }: NavItemP
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
className="no-underline"
|
className="no-underline"
|
||||||
title={collapsed ? label : undefined}
|
title={collapsed ? label : undefined}
|
||||||
|
onClick={onNavigate}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
|
@ -118,18 +120,24 @@ function NavItem({ label, icon: Icon, path, collapsed, active, badge }: NavItemP
|
||||||
|
|
||||||
type AppSidebarProps = {
|
type AppSidebarProps = {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
|
mobile?: boolean;
|
||||||
|
open?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AppSidebar({ collapsed }: AppSidebarProps) {
|
export function AppSidebar({ collapsed, mobile = false, open = true, onClose }: AppSidebarProps) {
|
||||||
const width = getSidebarWidth(collapsed);
|
const width = getSidebarWidth(collapsed);
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const activePath = getActiveNavPath(pathname);
|
const activePath = getActiveNavPath(pathname);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
variant="permanent"
|
variant={mobile ? "temporary" : "permanent"}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
ModalProps={{ keepMounted: true }}
|
||||||
sx={{
|
sx={{
|
||||||
width,
|
width: mobile ? 0 : width,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
"& .MuiDrawer-paper": {
|
"& .MuiDrawer-paper": {
|
||||||
width,
|
width,
|
||||||
|
|
@ -157,7 +165,13 @@ export function AppSidebar({ collapsed }: AppSidebarProps) {
|
||||||
{collapsed ? <BrandMark size="compact" /> : <BrandLockup size="compact" />}
|
{collapsed ? <BrandMark size="compact" /> : <BrandLockup size="compact" />}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box component="nav" className="flex-1 overflow-y-auto overflow-x-hidden" sx={{ py: 1 }}>
|
<Box
|
||||||
|
component="nav"
|
||||||
|
id="primary-navigation"
|
||||||
|
aria-label="Primary navigation"
|
||||||
|
className="flex-1 overflow-y-auto overflow-x-hidden"
|
||||||
|
sx={{ py: 1 }}
|
||||||
|
>
|
||||||
{navGroups.map((group, groupIndex) => (
|
{navGroups.map((group, groupIndex) => (
|
||||||
<Box key={group.title || `group-${groupIndex}`}>
|
<Box key={group.title || `group-${groupIndex}`}>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
|
|
@ -198,6 +212,7 @@ export function AppSidebar({ collapsed }: AppSidebarProps) {
|
||||||
badge={badge}
|
badge={badge}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
active={path === activePath}
|
active={path === activePath}
|
||||||
|
onNavigate={mobile ? onClose : undefined}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -24,15 +24,16 @@ export function PageHeader({
|
||||||
}: PageHeaderProps) {
|
}: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction={{ xs: "column", md: "row" }}
|
||||||
className={cn(
|
className={cn(
|
||||||
surface &&
|
surface &&
|
||||||
"-mx-2.5 -mt-2.5 shrink-0 border-b border-(--color-border) bg-(--color-bg-surface) px-5 py-4",
|
"-mx-2.5 -mt-2.5 shrink-0 border-b border-(--color-border) bg-(--color-bg-surface) px-5 py-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
sx={{
|
sx={{
|
||||||
alignItems: "center",
|
alignItems: { xs: "stretch", md: "center" },
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
|
gap: { xs: 2, md: 0 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack spacing={0}>
|
<Stack spacing={0}>
|
||||||
|
|
@ -64,7 +65,16 @@ export function PageHeader({
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
{Boolean(actions) && (
|
{Boolean(actions) && (
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "nowrap" }}>
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
useFlexGap
|
||||||
|
sx={{
|
||||||
|
alignItems: "center",
|
||||||
|
flexWrap: { xs: "wrap", md: "nowrap" },
|
||||||
|
width: { xs: "100%", md: "auto" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
{actions}
|
{actions}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
FileText,
|
FileText,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
MapPin,
|
||||||
Bell,
|
Bell,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Star,
|
Star,
|
||||||
|
|
@ -52,7 +53,10 @@ export const navGroups: NavGroup[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "CUSTOMERS & SITES",
|
title: "CUSTOMERS & SITES",
|
||||||
items: [{ label: "Accounts", icon: BriefcaseBusiness, path: "/accounts" }],
|
items: [
|
||||||
|
{ label: "Accounts", icon: BriefcaseBusiness, path: "/accounts" },
|
||||||
|
{ label: "Sites", icon: MapPin, path: "/locations" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// title: "INTERNAL",
|
// title: "INTERNAL",
|
||||||
|
|
|
||||||