diff --git a/e2e/assets.spec.ts b/e2e/assets.spec.ts index f26d956..20ad98b 100644 --- a/e2e/assets.spec.ts +++ b/e2e/assets.spec.ts @@ -18,7 +18,13 @@ test.describe.serial("phase 5 assets", () => { } }); - test("can create, edit, and update status for an asset", async ({ page }) => { + test("can create, edit, and update status for an asset", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium", + "Desktop flow: phones use asset cards and the asset back bar", + ); const email = process.env.E2E_AUTH_EMAIL; const password = process.env.E2E_AUTH_PASSWORD; @@ -41,16 +47,16 @@ test.describe.serial("phase 5 assets", () => { await fillRequiredFields(page); await page.getByRole("button", { name: "Create asset" }).click(); await expect( - page.getByRole("heading", { name: `${baseName} created` }), + page.getByRole("heading", { name: baseName, exact: true }), ).toBeVisible({ timeout: 20_000, }); - await page.getByRole("button", { name: "View asset" }).click(); + await page.getByRole("button", { name: "Open asset" }).click(); await page.waitForURL(/\/assets\/[^/]+$/, { timeout: 20_000 }); await expect(page.getByRole("heading", { name: baseName })).toBeVisible(); - await page.getByRole("link", { name: "Edit" }).click(); + await page.getByRole("link", { name: "Edit", exact: true }).click(); await page.waitForURL(/\/assets\/.+\/edit/, { timeout: 15_000 }); const nameInput = page.getByLabel(/Name/i); @@ -64,12 +70,7 @@ test.describe.serial("phase 5 assets", () => { await page.getByLabel("Change status").click(); await page.getByRole("option", { name: "Retired" }).click(); - await expect( - page - .locator("[data-slot='badge']") - .filter({ hasText: "Retired" }) - .first(), - ).toBeVisible(); + await expect(page.getByLabel("Change status")).toHaveText("Retired"); await page.goto("/assets"); await page.getByLabel("Search assets").fill(updatedName); @@ -77,31 +78,26 @@ test.describe.serial("phase 5 assets", () => { page.locator("tbody tr").filter({ hasText: updatedName }), ).toBeVisible(); - await page.getByLabel("Filter by status").click(); - await page.getByRole("option", { name: "Retired" }).click(); + await page + .getByRole("navigation", { name: "Status" }) + .getByRole("button", { name: /^Retired/ }) + .click(); await expect( page.locator("tbody tr").filter({ hasText: updatedName }), ).toBeVisible(); - await page - .locator("tbody tr") - .filter({ hasText: updatedName }) - .first() - .click(); + // Table rows open on click; the name cell is the safest hit target. + await page.getByRole("cell", { name: updatedName, exact: true }).click(); await page.waitForURL(/\/assets\/.+$/, { timeout: 15_000 }); await expect( page.getByRole("heading", { name: updatedName }), ).toBeVisible(); - await expect( - page - .locator("[data-slot='badge']") - .filter({ hasText: "Retired" }) - .first(), - ).toBeVisible(); + await expect(page.getByLabel("Change status")).toHaveText("Retired"); - await page.getByRole("button", { name: "Delete" }).click(); + await page.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Delete…" }).click(); await expect( - page.getByRole("heading", { name: "Delete asset" }), + page.getByRole("heading", { name: "Delete asset?" }), ).toBeVisible(); await page.getByRole("button", { name: "Delete asset" }).click(); await page.waitForURL( diff --git a/e2e/attachments.spec.ts b/e2e/attachments.spec.ts index 7023083..42281cf 100644 --- a/e2e/attachments.spec.ts +++ b/e2e/attachments.spec.ts @@ -30,7 +30,7 @@ test.describe.serial("phase 6 attachments", () => { await page.getByRole("button", { name: "Create asset" }).click(); await expect( - page.getByRole("heading", { name: `${assetName} created` }), + page.getByRole("heading", { name: assetName, exact: true }), ).toBeVisible({ timeout: 20_000, }); @@ -83,7 +83,7 @@ test.describe.serial("phase 6 attachments", () => { timeout: 15_000, }); - await page.getByRole("button", { name: "View asset" }).click(); + await page.getByRole("button", { name: "Open asset" }).click(); await page.waitForURL(/\/assets\/.+$/, { timeout: 20_000 }); await expect(page.getByRole("heading", { name: assetName })).toBeVisible(); }); diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts index b9c3fee..0ba5f17 100644 --- a/e2e/auth.spec.ts +++ b/e2e/auth.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Page } from "@playwright/test"; -import { getLeafPathSegment, type Landing } from "./helpers"; +import { getLeafPathSegment, hideNextDevTools, type Landing } from "./helpers"; async function getUnauthenticatedLanding(page: Page): Promise { await page.goto("/dashboard"); @@ -70,7 +70,9 @@ test.describe.serial("auth flow", () => { const email = `playwright-invalid-${Date.now()}@example.com`; await page.getByLabel("Email").fill(email); - await page.getByLabel("Password").fill("not-the-right-password"); + await page + .getByLabel("Password", { exact: true }) + .fill("not-the-right-password"); await expectNoLocalAuthProxyRequests(page, async () => { await page.getByRole("button", { name: "Sign in" }).click(); @@ -98,8 +100,9 @@ test.describe.serial("auth flow", () => { ); await expect.poll(() => getLeafPathSegment(page.url())).toBe("login"); + await hideNextDevTools(page); await page.getByLabel("Email").fill(email!); - await page.getByLabel("Password").fill(password!); + await page.getByLabel("Password", { exact: true }).fill(password!); await expectNoLocalAuthProxyRequests(page, async () => { await page.getByRole("button", { name: "Sign in" }).click(); @@ -111,14 +114,16 @@ test.describe.serial("auth flow", () => { ); }); - await page.getByLabel("Open user menu").click(); + // The shell mounts a user menu per layout (sidebar, rail, phone bar); + // only the one for this viewport is visible. + await page.getByLabel("Open user menu").filter({ visible: true }).click(); const logoutResponse = page.waitForResponse( (res) => res.url().endsWith("/api/auth/logout") && res.request().method() === "POST", { timeout: 10_000 }, ); - await page.getByRole("menuitem", { name: "Log out" }).click(); + await page.getByRole("menuitem", { name: "Sign out" }).click(); await logoutResponse; await page.waitForURL( (url) => getLeafPathSegment(url.toString()) === "login", diff --git a/e2e/categories-tags-locations.spec.ts b/e2e/categories-tags-locations.spec.ts index 8265e13..c9d9852 100644 --- a/e2e/categories-tags-locations.spec.ts +++ b/e2e/categories-tags-locations.spec.ts @@ -15,7 +15,11 @@ test.describe.serial("phase 3 pages", () => { test("admin can create category, tag, and nested locations", async ({ page, - }) => { + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium", + "Desktop flow: phones replace the location tree with a picker", + ); const email = process.env.E2E_AUTH_EMAIL; const password = process.env.E2E_AUTH_PASSWORD; @@ -39,56 +43,76 @@ test.describe.serial("phase 3 pages", () => { const childName = `PW Child ${stamp}`; const grandchildName = `PW Bin ${stamp}`; + // /categories and /tags redirect to the taxonomy tabs. await page.goto("/categories"); - await page.getByRole("button", { name: "Add Category" }).click(); - const categoryDialog = page.getByRole("dialog"); + await page.waitForURL(/\/taxonomy\?tab=categories/); + await page.getByRole("button", { name: "New category…" }).first().click(); + const categoryDialog = page.getByRole("dialog", { name: "New category" }); await categoryDialog.getByLabel("Name").fill(categoryName); await categoryDialog.getByLabel("Prefix").fill("PW"); await categoryDialog.getByLabel("Description").fill("Playwright category"); await categoryDialog - .getByRole("textbox", { name: "Color" }) - .fill("#2563EB"); - await page.getByRole("button", { name: "Create Category" }).click(); - await expect(page.getByText(categoryName)).toBeVisible(); + .getByRole("button", { name: "Create category" }) + .click(); + await expect(page.getByRole("link", { name: categoryName })).toBeVisible(); await page.goto("/tags"); - await page.getByRole("button", { name: "Add Tag" }).click(); - const tagDialog = page.getByRole("dialog"); + await page.waitForURL(/\/taxonomy\?tab=tags/); + await page.getByRole("button", { name: "New keyword…" }).first().click(); + const tagDialog = page.getByRole("dialog", { name: "New keyword" }); await tagDialog.getByLabel("Name").fill(tagName); - await tagDialog.getByRole("textbox", { name: "Color" }).fill("#E11D48"); - await page.getByRole("button", { name: "Create Tag" }).click(); - await expect(page.getByText(tagName)).toBeVisible(); + await tagDialog.getByRole("button", { name: "Create keyword" }).click(); + await expect( + page.getByRole("link", { name: `Assets with keyword ${tagName}` }), + ).toBeVisible(); + + // Locations: a site, a location inside it, and one inside that. Each new + // location becomes the selection and its row is revealed in the tree. + const tree = page.getByRole("list", { name: "Location tree" }); + // A row's select button reads as its name followed by its asset count. + const treeRow = (name: string) => + tree.getByRole("button", { name: new RegExp(`^${name}\\s*\\d+$`) }); + const detail = page.getByTestId("location-detail"); await page.goto("/locations"); - await page - .getByRole("button", { name: "Add root location" }) - .first() - .click(); - let locationDialog = page.getByRole("dialog"); + await page.getByRole("button", { name: "New location…" }).first().click(); + let locationDialog = page.getByRole("dialog", { name: "New location" }); await locationDialog.getByLabel("Name").fill(rootName); - await page.getByRole("button", { name: "Create location" }).click(); + await locationDialog + .getByRole("button", { name: "Create location" }) + .click(); await expect( - page.getByRole("button", { name: `Actions for ${rootName}` }), + detail.getByRole("heading", { level: 2, name: rootName }), ).toBeVisible(); + await expect(treeRow(rootName)).toHaveAttribute("aria-current", "true"); - await page.getByRole("button", { name: `Actions for ${rootName}` }).click(); - await page.getByRole("menuitem", { name: "Add child" }).click(); - locationDialog = page.getByRole("dialog"); - await locationDialog.getByLabel("Name").fill(childName); - await page.getByRole("button", { name: "Create location" }).click(); - await expect( - page.getByRole("button", { name: `Actions for ${childName}` }), - ).toBeVisible(); + for (const [parentName, name] of [ + [rootName, childName], + [childName, grandchildName], + ]) { + await page.getByRole("button", { name: "Location actions" }).click(); + await page + .getByRole("menuitem", { name: "New location inside…" }) + .click(); + locationDialog = page.getByRole("dialog", { name: "New location" }); + await locationDialog.getByLabel("Name").fill(name); + await locationDialog + .getByRole("button", { name: "Create location" }) + .click(); + await expect( + detail.getByRole("heading", { + level: 2, + name: `${name} in ${parentName}`, + }), + ).toBeVisible(); + await expect(treeRow(name)).toHaveAttribute("aria-current", "true"); + } - await page - .getByRole("button", { name: `Actions for ${childName}` }) - .click(); - await page.getByRole("menuitem", { name: "Add child" }).click(); - locationDialog = page.getByRole("dialog"); - await locationDialog.getByLabel("Name").fill(grandchildName); - await page.getByRole("button", { name: "Create location" }).click(); + // A location with something inside it cannot be deleted. + await treeRow(rootName).click(); + await page.getByRole("button", { name: "Location actions" }).click(); await expect( - page.getByRole("button", { name: `Actions for ${grandchildName}` }), - ).toBeVisible(); + page.getByRole("menuitem", { name: /Delete…/ }), + ).toHaveAttribute("aria-disabled", "true"); }); }); diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index 246e61d..dc8a3d7 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -6,6 +6,10 @@ import { signIn, } from "./helpers"; +// The dashboard title is today's date, e.g. "Saturday 7 November". +const DATE_HEADING = + /^(Mon|Tues|Wednes|Thurs|Fri|Satur|Sun)day \d{1,2} [A-Z][a-z]+$/; + function getStatusLabel(status: "active" | "retired") { return status === "retired" ? "Retired" : "Active"; } @@ -38,11 +42,11 @@ async function createAsset( await page.getByRole("button", { name: "Create asset" }).click(); await expect( - page.getByRole("heading", { name: `${options.name} created` }), + page.getByRole("heading", { name: options.name, exact: true }), ).toBeVisible({ timeout: 20_000, }); - await page.getByRole("button", { name: "View asset" }).click(); + await page.getByRole("button", { name: "Open asset" }).click(); await page.waitForURL(/\/assets\/(?!new$)[^/]+$/, { timeout: 20_000 }); } @@ -69,7 +73,7 @@ test.describe.serial("phase 10 dashboard", () => { await page.goto("/"); await page.waitForURL(/\/dashboard$/, { timeout: 20_000 }); await expect( - page.getByRole("heading", { name: /Welcome back|Dashboard/ }), + page.getByRole("heading", { level: 1, name: DATE_HEADING }), ).toBeVisible(); }); @@ -108,35 +112,23 @@ test.describe.serial("phase 10 dashboard", () => { await page.goto("/dashboard"); await expect( - page.getByRole("heading", { name: /Welcome back|Dashboard/ }), + page.getByRole("heading", { level: 1, name: DATE_HEADING }), ).toBeVisible(); - await expect( - page.locator('[data-dashboard-stat="total"] [data-dashboard-stat-value]'), - ).toHaveText(/\d+/); - await expect( - page.locator( - '[data-dashboard-stat="active"] [data-dashboard-stat-value]', - ), - ).toHaveText(/\d+/); - await expect( - page.locator( - '[data-dashboard-stat="retired"] [data-dashboard-stat-value]', - ), - ).toHaveText(/\d+/); - - const recentAssetsSection = page - .locator("section") - .filter({ hasText: "Recent assets" }) - .first(); + const stats = page.getByTestId("dashboard-stats-bar"); + await expect(stats).toContainText(/\d+\s*Assets/); + await expect(stats).toContainText(/\d+\s*Retired/); + + const recentAssetsSection = page.getByRole("region", { + name: "Recently updated", + }); await expect( recentAssetsSection.getByText(scheduledAssetName), ).toBeVisible(); await expect(recentAssetsSection.getByText(retiredAssetName)).toBeVisible(); - const serviceQueueSection = page - .locator("section") - .filter({ hasText: "Service queue" }) - .first(); + const serviceQueueSection = page.getByRole("region", { + name: "Service due", + }); await expect(serviceQueueSection).toBeVisible(); await expect( serviceQueueSection.getByText(scheduledAssetName), diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 8618143..0ee563e 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -34,10 +34,41 @@ export async function getLanding( return getLeafPathSegment(page.url()) === "setup" ? "setup" : "login"; } +/* + In dev, Next.js draws its dev-tools badge in a corner of the page. On a + phone it sits on top of the top bar's user menu and swallows taps, so tests + hide it. Production builds have no badge. +*/ +export async function hideNextDevTools(page: Page) { + await page.addInitScript(() => { + const style = document.createElement("style"); + style.textContent = "nextjs-portal { display: none !important; }"; + document.addEventListener("DOMContentLoaded", () => + document.head.appendChild(style), + ); + }); + // The page that is already open (client-side navigations keep it). + await page + .addStyleTag({ content: "nextjs-portal { display: none !important; }" }) + .catch(() => undefined); +} + +/* + A page's name: the h1 on tablet and desktop, the top bar title on phones + (where the page header is hidden). Only one of them is visible. +*/ +export function pageTitle(page: Page, name: string | RegExp) { + return page + .getByRole("heading", { level: 1, name }) + .or(page.getByTestId("mobile-top-bar-controls").getByText(name)) + .filter({ visible: true }); +} + export async function signIn(page: Page, email: string, password: string) { + await hideNextDevTools(page); await page.goto("/login"); await page.getByLabel("Email").fill(email); - await page.getByLabel("Password").fill(password); + await page.getByLabel("Password", { exact: true }).fill(password); await page.getByRole("button", { name: "Sign in" }).click(); await page.waitForURL( (url) => getLeafPathSegment(url.toString()) === "dashboard", diff --git a/e2e/labels.spec.ts b/e2e/labels.spec.ts index 8f0c75b..c58ee7b 100644 --- a/e2e/labels.spec.ts +++ b/e2e/labels.spec.ts @@ -1,10 +1,11 @@ import { expect, test } from "@playwright/test"; -import { fillRequiredFields, getLanding, signIn } from "./helpers"; +import { fillRequiredFields, getLanding, pageTitle, signIn } from "./helpers"; test.describe.serial("phase 9 label system", () => { test("admin can design a template and print single + batch labels", async ({ page, - }) => { + }, testInfo) => { + const isPhone = testInfo.project.name === "mobile-chromium"; const email = process.env.E2E_AUTH_EMAIL; const password = process.env.E2E_AUTH_PASSWORD; @@ -25,15 +26,15 @@ test.describe.serial("phase 9 label system", () => { await page.getByRole("button", { name: "Create asset" }).click(); await expect( - page.getByRole("heading", { name: `${assetName} created` }), + page.getByRole("heading", { name: assetName, exact: true }), ).toBeVisible({ timeout: 20_000 }); - await page.getByRole("button", { name: "View asset" }).click(); + await page.getByRole("button", { name: "Open asset" }).click(); await page.waitForURL(/\/assets\/(?!new$)[^/]+$/, { timeout: 20_000 }); const detailUrl = page.url(); await page.goto("/labels"); - await expect(page.getByRole("heading", { name: "Labels" })).toBeVisible(); + await expect(pageTitle(page, "Labels")).toBeVisible(); await expect(page.getByText("Thermal 57x32 mm")).toBeVisible({ timeout: 20_000, }); @@ -41,17 +42,23 @@ test.describe.serial("phase 9 label system", () => { timeout: 20_000, }); - await page.getByRole("button", { name: /^New$/ }).click(); - const templateName = `Playwright Label ${Date.now()}`; - await page.getByLabel("Name").fill(templateName); - await page.locator('[data-label-element="staticText"]').click(); - await page.locator("#label-element-text").fill("Deck A"); - await page.locator('[data-label-element="dataMatrix"]').click(); - await page.locator("[data-label-template-save]").click(); - await expect(page.getByText("Label template created")).toBeVisible(); - await expect( - page.getByRole("button", { name: new RegExp(templateName) }), - ).toBeVisible(); + // Phones get previews and printing; the drag-and-drop designer is + // desktop and tablet only. + if (!isPhone) { + await page.getByRole("button", { name: "New template" }).first().click(); + const templateName = `Playwright Label ${Date.now()}`; + await page.getByLabel("Name", { exact: true }).fill(templateName); + await page.locator('[data-label-element="staticText"]').click(); + await page.locator("#label-element-text").fill("Deck A"); + await page.locator('[data-label-element="dataMatrix"]').click(); + await page.locator("[data-label-template-save]").click(); + await expect(page.getByText("Label template created")).toBeVisible(); + await expect( + page + .getByRole("list", { name: "Label templates" }) + .getByRole("button", { name: new RegExp(templateName) }), + ).toBeVisible(); + } await page.goto(detailUrl); await page.getByRole("link", { name: "Print label" }).click(); @@ -69,15 +76,24 @@ test.describe.serial("phase 9 label system", () => { await page.getByRole("option", { name: "Compact 35x12 mm" }).click(); await expect(page.getByText("35 x 12 mm").first()).toBeVisible(); - await page.goto("/assets", { waitUntil: "domcontentloaded" }); - await page.getByRole("checkbox", { name: `Select ${assetName}` }).check(); - const batchPrintLink = page.getByRole("link", { name: "Print labels" }); - await expect(batchPrintLink).toHaveAttribute("href", /\/labels\/print\?/); - await batchPrintLink.click(); - await expect(page).toHaveURL(/\/labels\/print\?assetIds=/, { - timeout: 20_000, - }); - await expect(page.getByText("1 asset")).toBeVisible(); - await expect(page.getByRole("heading", { name: assetName })).toBeVisible(); + // Batch printing starts from the desktop selection tray; phones select + // with a long press instead. + if (!isPhone) { + await page.goto("/assets", { waitUntil: "domcontentloaded" }); + await page.getByRole("checkbox", { name: `Select ${assetName}` }).check(); + // The selection tray's link; the page header has "Print labels…" too. + const batchPrintLink = page + .getByRole("region", { name: "Selection" }) + .getByRole("link", { name: "Print labels…" }); + await expect(batchPrintLink).toHaveAttribute("href", /\/labels\/print\?/); + await batchPrintLink.click(); + await expect(page).toHaveURL(/\/labels\/print\?assetIds=/, { + timeout: 20_000, + }); + await expect(page.getByText("1 asset")).toBeVisible(); + await expect( + page.getByRole("heading", { name: assetName }), + ).toBeVisible(); + } }); }); diff --git a/e2e/mobile-shell.spec.ts b/e2e/mobile-shell.spec.ts index 8a8b3dd..58a376b 100644 --- a/e2e/mobile-shell.spec.ts +++ b/e2e/mobile-shell.spec.ts @@ -121,6 +121,14 @@ test.describe("mobile shell", () => { await page.goto("/dashboard"); await expect(page.getByTestId("bottom-nav")).toBeHidden(); - await expect(page.locator('[data-slot="sidebar-container"]')).toBeVisible(); + // The full sidebar (desktop) and the rail (tablet) are both labelled + // "Main"; exactly one is shown at desktop width, with the full nav. + const sidebar = page + .getByRole("complementary", { name: "Main" }) + .filter({ visible: true }); + await expect(sidebar).toHaveCount(1); + await expect( + sidebar.getByRole("link", { name: "Locations" }), + ).toBeVisible(); }); }); diff --git a/e2e/scan.spec.ts b/e2e/scan.spec.ts index e649d60..6c1e34b 100644 --- a/e2e/scan.spec.ts +++ b/e2e/scan.spec.ts @@ -22,8 +22,10 @@ async function authedScan(page: import("@playwright/test").Page) { } async function readAssetTag(page: import("@playwright/test").Page) { + // The hero chip is desktop/tablet only; phones show the tag in the back + // bar. Either way the text is in the DOM. const tagBadge = page.getByTestId("asset-tag"); - await expect(tagBadge).toBeVisible(); + await expect(tagBadge).toBeAttached(); const assetTag = (await tagBadge.textContent())?.trim() ?? ""; expect(assetTag.length).toBeGreaterThan(0); return assetTag; @@ -47,10 +49,10 @@ test.describe("scan feature", () => { await fillRequiredFields(page); await page.getByRole("button", { name: "Create asset" }).click(); await expect( - page.getByRole("heading", { name: `${name} created` }), + page.getByRole("heading", { name: name, exact: true }), ).toBeVisible({ timeout: 20_000 }); - await page.getByRole("button", { name: "View asset" }).click(); + await page.getByRole("button", { name: "Open asset" }).click(); await page.waitForURL(/\/assets\/[^/]+$/, { timeout: 20_000 }); const assetTag = await readAssetTag(page); @@ -82,9 +84,9 @@ test.describe("scan feature", () => { await fillRequiredFields(page); await page.getByRole("button", { name: "Create asset" }).click(); await expect( - page.getByRole("heading", { name: `${name} created` }), + page.getByRole("heading", { name: name, exact: true }), ).toBeVisible({ timeout: 20_000 }); - await page.getByRole("button", { name: "View asset" }).click(); + await page.getByRole("button", { name: "Open asset" }).click(); await page.waitForURL(/\/assets\/[^/]+$/, { timeout: 20_000 }); const assetTag = await readAssetTag(page); const assetUrl = page.url(); diff --git a/e2e/search.spec.ts b/e2e/search.spec.ts index c22e9c5..04c4c94 100644 --- a/e2e/search.spec.ts +++ b/e2e/search.spec.ts @@ -8,21 +8,14 @@ async function createAssetForSearch(page: Page, name: string, notes: string) { await page.getByLabel("Notes").fill(notes); await page.getByRole("button", { name: "Create asset" }).click(); await expect( - page.getByRole("heading", { name: `${name} created` }), + page.getByRole("heading", { name: name, exact: true }), ).toBeVisible({ timeout: 20_000, }); - await page.getByRole("button", { name: "View asset" }).click(); + await page.getByRole("button", { name: "Open asset" }).click(); await page.waitForURL(/\/assets\/(?!new$)[^/]+$/, { timeout: 20_000 }); - const assetTag = ( - await page - .locator("section") - .first() - .locator(".font-mono") - .first() - .textContent() - )?.trim(); + const assetTag = (await page.getByTestId("asset-tag").textContent())?.trim(); expect(assetTag).toMatch(/[A-Z]{2,4}-\d{4}/); return { diff --git a/e2e/service-groups.spec.ts b/e2e/service-groups.spec.ts index 94c13f4..21eb6f7 100644 --- a/e2e/service-groups.spec.ts +++ b/e2e/service-groups.spec.ts @@ -40,31 +40,44 @@ test.describe.serial("phase 7.5 service groups and records", () => { const dueDate = getUtcIsoDateWithOffset(-1); await page.goto("/services/groups"); - await page.getByRole("button", { name: "Create group" }).click(); - await page.getByLabel("Name").fill(groupName); - await page.getByLabel("Description").fill("Playwright service group"); await page - .getByRole("dialog", { name: "Create service group" }) - .getByRole("button", { name: "Create group" }) + .getByRole("button", { name: "New service plan…" }) + .first() .click(); - await expect(page.getByText(groupName)).toBeVisible(); - - await page.getByRole("link", { name: groupName }).first().click(); + const planDialog = page.getByRole("dialog", { name: "New service plan" }); + await planDialog.getByLabel("Name").fill(groupName); + await planDialog + .getByLabel(/^Description/) + .fill("Playwright service group"); + await planDialog.getByRole("button", { name: "Create plan" }).click(); + // Plans render as a table on desktop and a list on phones. + const planLink = page + .getByRole("link", { name: groupName }) + .filter({ visible: true }); + await expect(planLink).toBeVisible(); + + await planLink.click(); await page.waitForURL(/\/services\/groups\/[^/]+$/, { timeout: 20_000 }); await expect( page.getByRole("heading", { name: groupName, exact: true }), ).toBeVisible(); - await page.getByRole("button", { name: "Add field" }).click(); - await page.getByLabel("Label").fill("Technician note"); - await page.getByRole("button", { name: "Create field" }).click(); - await expect(page.getByText("Technician note")).toBeVisible(); - - await page.getByRole("button", { name: "Add field" }).click(); - await page.getByLabel("Label").fill("Verified"); - await page.getByLabel("Type").selectOption("checkbox"); - await page.getByRole("button", { name: "Create field" }).click(); - await expect(page.getByText("Verified")).toBeVisible(); + for (const [label, type] of [ + ["Technician note", null], + ["Verified", "checkbox"], + ] as const) { + await page.getByRole("button", { name: "Add field…" }).first().click(); + const fieldDialog = page.getByRole("dialog", { + name: "Add required field", + }); + await fieldDialog.getByLabel("Label").fill(label); + if (type) await fieldDialog.getByLabel("Type").selectOption(type); + await fieldDialog.getByRole("button", { name: "Create field" }).click(); + await expect(fieldDialog).toBeHidden(); + await expect( + page.getByText(label, { exact: true }).filter({ visible: true }), + ).toBeVisible(); + } await page.goto("/assets/new"); await page.getByLabel(/Name/i).fill(assetName); @@ -78,11 +91,11 @@ test.describe.serial("phase 7.5 service groups and records", () => { await page.getByRole("button", { name: "Create asset" }).click(); await expect( - page.getByRole("heading", { name: `${assetName} created` }), + page.getByRole("heading", { name: assetName, exact: true }), ).toBeVisible({ timeout: 20_000, }); - await page.getByRole("button", { name: "View asset" }).click(); + await page.getByRole("button", { name: "Open asset" }).click(); await page.waitForURL(/\/assets\/(?!new$)[^/]+$/, { timeout: 20_000 }); const assetId = page.url().split("/").filter(Boolean).at(-1); expect(assetId).toBeTruthy(); @@ -93,9 +106,9 @@ test.describe.serial("phase 7.5 service groups and records", () => { }); const assetLink = page.getByRole("link", { name: assetName, exact: true }); await expect(assetLink).toBeVisible({ timeout: 20_000 }); - const scheduleRow = assetLink.locator("xpath=ancestor::article[1]"); - await expect(scheduleRow).toBeVisible({ timeout: 20_000 }); - await scheduleRow.getByRole("button", { name: "Log service" }).click(); + await page + .getByRole("button", { name: `Log service for ${assetName}` }) + .click(); const plannerDialog = page.getByRole("dialog", { name: new RegExp(`Log service: ${assetName}`), @@ -133,29 +146,28 @@ test.describe.serial("phase 7.5 service groups and records", () => { await expect(plannerDialog).toBeHidden(); await page.goto(`/assets/${assetId}`); - const serviceHistoryHeading = page.getByRole("heading", { - name: "Service history", - exact: true, - }); - await expect(serviceHistoryHeading).toBeVisible(); - const serviceHistorySection = serviceHistoryHeading.locator( - "xpath=ancestor::section[1]", - ); - const firstHistoryRecord = serviceHistorySection.locator("details").first(); - await expect(firstHistoryRecord).toBeVisible(); - await firstHistoryRecord.locator("summary").click(); + const serviceHistorySection = page.getByTestId("service-history"); await expect( - firstHistoryRecord.getByText("Completed from services list"), + serviceHistorySection.getByRole("heading", { name: /^Service history/ }), ).toBeVisible(); + // Each record shows its description; fields and files sit behind a + // disclosure. + const firstHistoryRecord = serviceHistorySection + .getByRole("listitem") + .filter({ hasText: "Completed from services list" }); + await expect(firstHistoryRecord).toBeVisible(); + await firstHistoryRecord + .getByText("Fields and files", { exact: true }) + .click(); await expect( firstHistoryRecord.getByText("service-report.pdf"), ).toBeVisible(); await serviceHistorySection - .getByRole("button", { name: "Log manual service" }) + .getByRole("button", { name: "Log other service…" }) .click(); const manualDialog = page.getByRole("dialog", { - name: "Log manual service", + name: "Log other service", }); await manualDialog .getByLabel(/^Description/i) diff --git a/e2e/service-scheduling.spec.ts b/e2e/service-scheduling.spec.ts index 5857a27..a084c54 100644 --- a/e2e/service-scheduling.spec.ts +++ b/e2e/service-scheduling.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { fillRequiredFields, getLanding, signIn } from "./helpers"; +import { fillRequiredFields, getLanding, pageTitle, signIn } from "./helpers"; function escapeRegex(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -70,13 +70,13 @@ test.describe.serial("phase 7 service scheduling", () => { await page.goto("/services"); await expect( - page.getByText( - "Service scheduling is disabled by an admin. Enable it in Settings to use the planner views.", - ), + page.getByText("Service scheduling is turned off."), ).toBeVisible(); await page.goto("/assets/new"); - await expect(page.getByText("Preventive schedule")).toHaveCount(0); + await expect( + page.getByRole("heading", { name: "Service schedule" }), + ).toHaveCount(0); await page.goto("/settings"); const reenabledSchedulingSwitch = page.getByRole("switch", { @@ -87,7 +87,9 @@ test.describe.serial("phase 7 service scheduling", () => { await expect(page.getByText("Service scheduling enabled")).toBeVisible(); await page.goto("/assets/new"); - await expect(page.getByText("Preventive schedule")).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Service schedule" }), + ).toBeVisible(); const assetName = `PW Service Schedule ${Date.now()}`; const dueDate = getUtcIsoDateWithOffset(5); @@ -99,12 +101,12 @@ test.describe.serial("phase 7 service scheduling", () => { await page.getByRole("button", { name: "Create asset" }).click(); await expect( - page.getByRole("heading", { name: `${assetName} created` }), + page.getByRole("heading", { name: assetName, exact: true }), ).toBeVisible({ timeout: 20_000, }); - await page.getByRole("button", { name: "View asset" }).click(); + await page.getByRole("button", { name: "Open asset" }).click(); await page.waitForURL(/\/assets\/(?!new$)[^/]+$/, { timeout: 20_000 }); const detailUrl = page.url(); const assetId = detailUrl.split("/").filter(Boolean).at(-1); @@ -113,31 +115,34 @@ test.describe.serial("phase 7 service scheduling", () => { await page.goto("/services"); const scheduleRow = page - .locator("article") + .getByTestId("services-scheduled-list") + .getByRole("listitem") .filter({ hasText: assetName }) .first(); await expect(scheduleRow).toBeVisible({ timeout: 20_000 }); await expect(scheduleRow).toContainText(getDisplayDatePattern(dueDate)); await page.goto("/dashboard"); - const upcomingServicesSection = page - .locator("section") - .filter({ hasText: "Service queue" }) - .first(); + const upcomingServicesSection = page.getByRole("region", { + name: "Service due", + }); await expect(upcomingServicesSection).toBeVisible(); await page.goto("/services/calendar"); + await expect(pageTitle(page, /^Services/)).toBeVisible(); + const servicesTabs = page.getByRole("navigation", { + name: "Services sections", + }); await expect( - page.getByRole("heading", { name: "Services Calendar" }), - ).toBeVisible(); + servicesTabs.getByRole("link", { name: "Calendar" }), + ).toHaveAttribute("aria-current", "page"); await expect(page.getByText("Loading service calendar...")).not.toBeVisible( { timeout: 20_000, }, ); - await expect(page.locator('a[href="/services"]').first()).toBeVisible(); await expect( - page.locator('a[href="/services/calendar"]').first(), + servicesTabs.getByRole("link", { name: "Schedule" }), ).toBeVisible(); const targetMonthLabel = getMonthLabelForIsoDate(dueDate); const monthHeading = page.locator("[data-testid='calendar-month-heading']"); @@ -147,7 +152,7 @@ test.describe.serial("phase 7 service scheduling", () => { if (currentMonth === targetMonthLabel) { break; } - await page.getByRole("button", { name: /^Next$/ }).click(); + await page.getByRole("button", { name: "Next month" }).click(); } await expect(monthHeading.first()).toHaveText(targetMonthLabel); @@ -159,6 +164,7 @@ test.describe.serial("phase 7 service scheduling", () => { }) .first(); await expect(dayCell).toBeVisible(); + // The day lists its due assets (a busy day folds the rest into "+N"). await expect(dayCell.getByRole("button").first()).toBeVisible(); }); }); diff --git a/e2e/services-lifecycle.spec.ts b/e2e/services-lifecycle.spec.ts index 455c2d6..93fcb7e 100644 --- a/e2e/services-lifecycle.spec.ts +++ b/e2e/services-lifecycle.spec.ts @@ -64,12 +64,12 @@ async function createScheduledAsset( await page.getByRole("button", { name: "Create asset" }).click(); await expect( - page.getByRole("heading", { name: `${assetName} created` }), + page.getByRole("heading", { name: assetName, exact: true }), ).toBeVisible({ timeout: 20_000, }); - await page.getByRole("button", { name: "View asset" }).click(); + await page.getByRole("button", { name: "Open asset" }).click(); await page.waitForURL(/\/assets\/(?!new$)[^/]+$/, { timeout: 20_000 }); const assetId = page.url().split("/").filter(Boolean).at(-1); @@ -77,11 +77,17 @@ async function createScheduledAsset( return assetId!; } +function providerRow(page: Page, name: string) { + return page.getByRole("listitem").filter({ + has: page.getByRole("heading", { name, exact: true }), + }); +} + async function createProvider(page: Page, name: string) { const normalizedEmailSeed = name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); await page.goto("/services/providers"); - await page.getByRole("button", { name: "Add provider" }).click(); - const dialog = page.getByRole("dialog", { name: "Add provider" }); + await page.getByRole("button", { name: "New provider…" }).first().click(); + const dialog = page.getByRole("dialog", { name: "New provider" }); await dialog.getByLabel("Name").fill(name); await dialog .getByLabel("Contact email") @@ -90,38 +96,37 @@ async function createProvider(page: Page, name: string) { await dialog.getByLabel("Notes").fill("Playwright provider"); await dialog.getByRole("button", { name: "Create provider" }).click(); await expect(page.getByText("Service provider created")).toBeVisible(); - await expect( - page.locator("article").filter({ hasText: name }).first(), - ).toBeVisible(); + await expect(providerRow(page, name)).toBeVisible(); } async function editProvider(page: Page, name: string, nextName: string) { - const providerCard = page - .locator("article") - .filter({ hasText: name }) - .first(); - await providerCard.getByRole("button", { name: "Edit" }).click(); + await providerRow(page, name).getByRole("button", { name: "Edit…" }).click(); const dialog = page.getByRole("dialog", { name: "Edit provider" }); await dialog.getByLabel("Name").fill(nextName); await dialog.getByLabel("Notes").fill("Edited by Playwright"); await dialog.getByRole("button", { name: "Save provider" }).click(); await expect(page.getByText("Service provider updated")).toBeVisible(); - await expect( - page.locator("article").filter({ hasText: nextName }).first(), - ).toBeVisible(); + await expect(providerRow(page, nextName)).toBeVisible(); } async function deleteProvider(page: Page, name: string) { - const providerCard = page - .locator("article") - .filter({ hasText: name }) - .first(); - await providerCard.getByRole("button", { name: "Delete" }).click(); - await page.getByRole("button", { name: "Delete provider" }).click(); + await page + .getByRole("button", { name: `Delete ${name}`, exact: true }) + .click(); + await page + .getByRole("dialog", { name: "Delete provider?" }) + .getByRole("button", { name: "Delete provider" }) + .click(); await expect(page.getByText("Service provider deleted")).toBeVisible(); - await expect(page.locator("article").filter({ hasText: name })).toHaveCount( - 0, - ); + await expect(providerRow(page, name)).toHaveCount(0); +} + +function scheduleRowFor(page: Page, assetName: string) { + return page + .getByTestId("services-scheduled-list") + .getByRole("listitem") + .filter({ hasText: assetName }) + .first(); } async function completeScheduledService({ @@ -139,7 +144,9 @@ async function completeScheduledService({ providerName?: string; cost?: string; }) { - await row.getByRole("button", { name: "Log service" }).click(); + await row + .getByRole("button", { name: `Log service for ${assetName}` }) + .click(); const dialog = page.getByRole("dialog", { name: new RegExp(`Log service: ${escapeRegex(assetName)}`), }); @@ -168,18 +175,13 @@ async function logManualService({ providerName?: string; cost?: string; }) { - const serviceHistorySection = page - .locator("section") - .filter({ - has: page.getByRole("heading", { name: "Service history", exact: true }), - }) - .first(); + const serviceHistorySection = page.getByTestId("service-history"); await serviceHistorySection - .getByRole("button", { name: "Log manual service" }) + .getByRole("button", { name: "Log other service…" }) .click(); - const dialog = page.getByRole("dialog", { name: "Log manual service" }); + const dialog = page.getByRole("dialog", { name: "Log other service" }); await dialog.getByLabel(/^Description/i).fill(description); if (providerName) { await dialog.getByLabel(/^Provider$/i).click(); @@ -237,10 +239,7 @@ test.describe.serial("phase 8 service lifecycle", () => { const assetId = await createScheduledAsset(page, assetName, dueDate); await page.goto("/services"); - const scheduleRow = page - .locator("article") - .filter({ hasText: assetName }) - .first(); + const scheduleRow = scheduleRowFor(page, assetName); await expect(scheduleRow).toBeVisible({ timeout: 20_000 }); await expect(scheduleRow).toContainText(getDisplayDatePattern(dueDate)); @@ -256,20 +255,14 @@ test.describe.serial("phase 8 service lifecycle", () => { await expect(scheduleRow).toContainText( getDisplayDatePattern(expectedNextDue), ); - await expect(scheduleRow).toContainText(description); + // The row now reads "Last by ". await expect(scheduleRow).toContainText(providerName); - await expect(scheduleRow.getByText(/^Overdue$/)).toHaveCount(0); + await expect(scheduleRow.getByText(/^Overdue \d+( days?|d)$/)).toHaveCount( + 0, + ); await page.goto(`/assets/${assetId}`); - const serviceHistorySection = page - .locator("section") - .filter({ - has: page.getByRole("heading", { - name: "Service history", - exact: true, - }), - }) - .first(); + const serviceHistorySection = page.getByTestId("service-history"); await expect(serviceHistorySection).toBeVisible(); await expect(serviceHistorySection.getByText(description)).toBeVisible(); await expect(serviceHistorySection.getByText(providerName)).toBeVisible(); @@ -314,18 +307,12 @@ test.describe.serial("phase 8 service lifecycle", () => { cost: "88.00", }); - const serviceHistorySection = page - .locator("section") - .filter({ - has: page.getByRole("heading", { - name: "Service history", - exact: true, - }), - }) - .first(); - await expect(serviceHistorySection).toContainText( + // Logging a record moves the schedule on; the next service card shows + // the new date. + await expect(page.getByTestId("next-service-panel")).toContainText( getDisplayDatePattern(expectedNextDue), ); + const serviceHistorySection = page.getByTestId("service-history"); await expect(serviceHistorySection.getByText(providerName)).toBeVisible(); await expect( serviceHistorySection.getByText(/Cost 88([.,]00)?/), @@ -364,12 +351,14 @@ test.describe.serial("phase 8 service lifecycle", () => { await createScheduledAsset(page, assetName, dueDate); await page.goto("/services"); - const scheduleRow = page - .locator("article") - .filter({ hasText: assetName }) - .first(); + const scheduleRow = scheduleRowFor(page, assetName); await expect(scheduleRow).toBeVisible({ timeout: 20_000 }); - await expect(scheduleRow).toContainText("Overdue"); + // The row carries a due tag per layout; one is visible. + await expect( + scheduleRow + .getByText(/^Overdue \d+( days?|d)$/) + .filter({ visible: true }), + ).toBeVisible(); await completeScheduledService({ page, @@ -381,8 +370,9 @@ test.describe.serial("phase 8 service lifecycle", () => { await expect(scheduleRow).toContainText( getDisplayDatePattern(expectedNextDue), ); - await expect(scheduleRow.getByText(/^Overdue$/)).toHaveCount(0); - await expect(scheduleRow.getByText(/day[s]? overdue/i)).toHaveCount(0); - await expect(scheduleRow).toContainText(description); + await expect(scheduleRow.getByText(/^Overdue \d+( days?|d)$/)).toHaveCount( + 0, + ); + await expect(scheduleRow).toContainText(/Last /); }); }); diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index a3d7954..49f55df 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { getLanding, signIn } from "./helpers"; +import { getLanding, pageTitle, signIn } from "./helpers"; test.describe.serial("settings page", () => { test("protected settings page redirects when unauthenticated", async ({ @@ -29,7 +29,7 @@ test.describe.serial("settings page", () => { await signIn(page, email!, password!); await page.goto("/settings"); - await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible(); + await expect(pageTitle(page, "Settings")).toBeVisible(); // Admin-only sections await expect( diff --git a/e2e/taxonomy.spec.ts b/e2e/taxonomy.spec.ts index 0252fe4..289c02a 100644 --- a/e2e/taxonomy.spec.ts +++ b/e2e/taxonomy.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { getLanding, signIn } from "./helpers"; +import { getLanding, pageTitle, signIn } from "./helpers"; test.describe.serial("taxonomy page", () => { test("protected taxonomy page redirects when unauthenticated", async ({ @@ -29,27 +29,31 @@ test.describe.serial("taxonomy page", () => { await signIn(page, email!, password!); await page.goto("/taxonomy"); - await expect(page.getByRole("heading", { name: "Taxonomy" })).toBeVisible(); + await expect(pageTitle(page, "Taxonomy")).toBeVisible(); // Default tab is categories await expect(page.getByRole("tab", { name: "Categories" })).toBeVisible(); - await expect(page.getByRole("tab", { name: "Tags" })).toBeVisible(); - await expect(page.getByRole("tab", { name: "Fields" })).toBeVisible(); + await expect(page.getByRole("tab", { name: "Keywords" })).toBeVisible(); + await expect( + page.getByRole("tab", { name: "Custom fields" }), + ).toBeVisible(); // Categories tab shows content await expect( - page.getByRole("heading", { name: "Categories" }), + page.getByRole("heading", { name: /^Categories/ }), ).toBeVisible(); - // Switch to tags tab - await page.getByRole("tab", { name: "Tags" }).click(); - await expect(page.getByRole("heading", { name: "Tags" })).toBeVisible(); + // Switch to the keywords tab ("tags" in the URL) + await page.getByRole("tab", { name: "Keywords" }).click(); + await expect( + page.getByRole("heading", { name: /^Keywords/ }), + ).toBeVisible(); await expect(page).toHaveURL(/tab=tags/); // Switch to fields tab - await page.getByRole("tab", { name: "Fields" }).click(); + await page.getByRole("tab", { name: "Custom fields" }).click(); await expect( - page.getByRole("heading", { name: "Field definitions" }), + page.getByRole("heading", { name: /^Custom fields/ }), ).toBeVisible(); await expect(page).toHaveURL(/tab=fields/); }); @@ -79,28 +83,31 @@ test.describe.serial("taxonomy page", () => { await page.goto("/taxonomy?tab=fields"); await expect( - page.getByRole("heading", { name: "Field definitions" }), + page.getByRole("heading", { name: /^Custom fields/ }), ).toBeVisible(); - // Create field — the page renders an "Add field" button in the header - // and another in the empty state; click the first one. - await page.getByRole("button", { name: "Add field" }).first().click(); - const dialog = page.getByRole("dialog"); + // Create field — the header and the empty state can both offer + // "New field…"; click the first one. + await page.getByRole("button", { name: "New field…" }).first().click(); + const dialog = page.getByRole("dialog", { name: "New field" }); await dialog.getByLabel("Name").fill(fieldName); - await dialog.getByRole("button", { name: /Create/i }).click(); - const fieldRow = page.getByRole("cell", { name: fieldName, exact: true }); + await dialog.getByRole("button", { name: "Create field" }).click(); + // The fields render as a table on desktop and a list on phones; only + // one of them is visible. + const fieldRow = page + .getByText(fieldName, { exact: true }) + .filter({ visible: true }); await expect(fieldRow).toBeVisible({ timeout: 10_000 }); // Delete field via actions menu await page .getByRole("button", { name: `Actions for ${fieldName}` }) + .filter({ visible: true }) .click(); - await page.getByRole("menuitem", { name: "Delete" }).click(); - const confirmDialog = page - .getByRole("alertdialog") - .or(page.getByRole("dialog").filter({ hasText: "Delete" })); - await confirmDialog.getByRole("button", { name: /Delete/i }).click(); - await expect(fieldRow).not.toBeVisible({ timeout: 10_000 }); + await page.getByRole("menuitem", { name: "Delete…" }).click(); + const confirmDialog = page.getByRole("dialog", { name: "Delete field?" }); + await confirmDialog.getByRole("button", { name: "Delete field" }).click(); + await expect(fieldRow).toHaveCount(0, { timeout: 10_000 }); }); test("/fields redirects to /taxonomy?tab=fields", async ({ page }) => { @@ -123,7 +130,7 @@ test.describe.serial("taxonomy page", () => { await page.goto("/fields"); await page.waitForURL(/\/taxonomy\?tab=fields/, { timeout: 10_000 }); await expect( - page.getByRole("heading", { name: "Field definitions" }), + page.getByRole("heading", { name: /^Custom fields/ }), ).toBeVisible(); }); }); diff --git a/eslint.config.mjs b/eslint.config.mjs index 05d5e64..d246830 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,6 +17,8 @@ const eslintConfig = defineConfig([ "pb_data/**", "pb_migrations/**", "bin/**", + // Local design references (generated mockups, not app code). + "docs/design/**", ]), ]); diff --git a/next.config.ts b/next.config.ts index 16dcece..2314f69 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,11 @@ import type { NextConfig } from "next"; +import packageJson from "./package.json"; const nextConfig: NextConfig = { output: "standalone", + env: { + NEXT_PUBLIC_APP_VERSION: packageJson.version, + }, turbopack: { root: process.cwd(), }, diff --git a/src/__tests__/components/assets/asset-card-list.test.tsx b/src/__tests__/components/assets/asset-card-list.test.tsx index 25e4b7f..96d9801 100644 --- a/src/__tests__/components/assets/asset-card-list.test.tsx +++ b/src/__tests__/components/assets/asset-card-list.test.tsx @@ -1,6 +1,8 @@ -import { describe, it, expect, vi } from "vitest"; +import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { act, render, screen, fireEvent, within } from "@testing-library/react"; + +const pushSpy = vi.fn(); vi.mock("@/lib/api/assets", async () => { const actual = @@ -11,9 +13,32 @@ vi.mock("@/lib/api/assets", async () => { }); vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn() }), + useRouter: () => ({ push: pushSpy }), +})); + +vi.mock("@/components/assets/asset-actions-sheet", () => ({ + AssetActionsSheet: ({ + open, + asset, + onOpenChange, + }: { + open: boolean; + asset: { name: string } | null; + onOpenChange: (open: boolean) => void; + }) => + open ? ( +
+ {asset?.name} + +
+ ) : null, })); +const NOW = new Date(2026, 10, 6, 12, 0, 0).getTime(); +const DAY = 24 * 60 * 60 * 1000; + import { AssetCardList } from "@/components/assets/asset-card-list"; import type { AssetListItem } from "@/components/assets/types"; @@ -33,7 +58,7 @@ function makeRow(overrides: Partial = {}): AssetListItem { tagIds: ["t1", "t2"], tagNames: ["electric", "cordless"], createdAt: 0, - updatedAt: 0, + updatedAt: NOW - DAY, ...overrides, }; } @@ -50,6 +75,16 @@ function renderList(rows: AssetListItem[], loading = false) { } describe("AssetCardList", () => { + beforeEach(() => { + pushSpy.mockReset(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it("renders skeleton rows while loading with no data", () => { renderList([], true); expect(screen.getByTestId("asset-card-list-loading")).toBeInTheDocument(); @@ -60,7 +95,7 @@ describe("AssetCardList", () => { expect(screen.getByText(/no assets match/i)).toBeInTheDocument(); }); - it("renders one card per row with name, tag, location, and tag chips", () => { + it("renders one card link per row with tag and name", () => { renderList([ makeRow(), makeRow({ @@ -68,50 +103,155 @@ describe("AssetCardList", () => { name: "Lathe", assetTag: "AST-0048", locationPath: "Workshop / Bench 5", - tagNames: ["legacy"], }), ]); - expect(screen.getByTestId("asset-card-a1")).toBeInTheDocument(); - expect(screen.getByTestId("asset-card-a2")).toBeInTheDocument(); - expect(screen.getByText("Drill #47")).toBeInTheDocument(); - expect(screen.getByText("AST-0047")).toBeInTheDocument(); - expect(screen.getByText("Workshop / Bench 3")).toBeInTheDocument(); - expect(screen.getByText("Workshop / Bench 5")).toBeInTheDocument(); - expect(screen.getByText("electric")).toBeInTheDocument(); - expect(screen.getByText("cordless")).toBeInTheDocument(); - expect(screen.getByText("legacy")).toBeInTheDocument(); - }); - - it("shows an overflow count when more than three tags", () => { + + const drill = screen.getByRole("link", { name: "AST-0047, Drill #47" }); + expect(drill).toHaveAttribute("href", "/assets/a1"); + expect(drill).toHaveAttribute("data-testid", "asset-card-a1"); + expect( + screen.getByRole("link", { name: "AST-0048, Lathe" }), + ).toHaveAttribute("href", "/assets/a2"); + }); + + it("shows the location path and short relative time on line two", () => { + renderList([makeRow()]); + expect(screen.getByTestId("asset-card-a1")).toHaveTextContent( + "Workshop / Bench 3 · 1d", + ); + }); + + it("leads line two with the category when asked", () => { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + render( + + + , + ); + const card = screen.getByTestId("asset-card-a1"); + expect(card).toHaveTextContent("Power tools · 1d"); + expect(card).not.toHaveTextContent("Bench 3"); + }); + + it("says No location when an asset has none", () => { + renderList([makeRow({ locationPath: null, locationId: null })]); + expect( + within(screen.getByTestId("asset-card-a1")).getByText("No location"), + ).toBeInTheDocument(); + }); + + it("does not show keyword chips on cards", () => { + renderList([makeRow()]); + expect(screen.queryByText("electric")).toBeNull(); + expect(screen.queryByText("cordless")).toBeNull(); + }); + + it("adds a status line only for non-active assets", () => { renderList([ - makeRow({ - tagNames: ["one", "two", "three", "four", "five"], - }), + makeRow(), + makeRow({ id: "a2", name: "Lathe", status: "under_repair" }), ]); - // three shown + overflow +2 - expect(screen.getByText("one")).toBeInTheDocument(); - expect(screen.getByText("two")).toBeInTheDocument(); - expect(screen.getByText("three")).toBeInTheDocument(); - expect(screen.getByText("+2")).toBeInTheDocument(); - expect(screen.queryByText("four")).toBeNull(); + expect( + within(screen.getByTestId("asset-card-a1")).queryByText("Active"), + ).toBeNull(); + expect( + within(screen.getByTestId("asset-card-a2")).getByText("Under repair"), + ).toBeInTheDocument(); }); - it("renders an always-visible kebab that opens the actions sheet", async () => { + it("navigates to the asset on a plain tap", () => { renderList([makeRow()]); - const kebab = screen.getByTestId("asset-card-kebab-a1"); - expect(kebab).toBeVisible(); - fireEvent.click(kebab); - // The sheet opens via AssetActionsSheet; since the fetched asset is null - // it doesn't render the action grid but the sheet is open. - // We verify the kebab handler fired by seeing the list still mounted. - expect(screen.getByTestId("asset-card-a1")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("asset-card-a1")); + expect(pushSpy).toHaveBeenCalledWith("/assets/a1"); + expect(screen.queryByRole("dialog", { name: "Asset actions" })).toBeNull(); }); - it("includes a linked overlay for tapping the whole card to open details", () => { + it("leaves modifier and middle clicks to the browser so new tabs work", () => { renderList([makeRow()]); const card = screen.getByTestId("asset-card-a1"); - const link = card.querySelector("a") as HTMLAnchorElement; - expect(link).not.toBeNull(); - expect(link.getAttribute("href")).toBe("/assets/a1"); + for (const init of [ + { metaKey: true }, + { ctrlKey: true }, + { shiftKey: true }, + { button: 1 }, + ]) { + const notCancelled = fireEvent.click(card, init); + expect(notCancelled).toBe(true); + } + expect(pushSpy).not.toHaveBeenCalled(); + }); + + it("opens the actions sheet from a named button on each card", () => { + renderList([makeRow()]); + fireEvent.click( + screen.getByRole("button", { name: "Asset actions, AST-0047" }), + ); + expect( + screen.getByRole("dialog", { name: "Asset actions" }), + ).toHaveTextContent("Drill #47"); + expect(pushSpy).not.toHaveBeenCalled(); + }); + + it("press and hold also opens the actions sheet", () => { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + vi.setSystemTime(NOW); + try { + renderList([makeRow()]); + + const card = screen.getByTestId("asset-card-a1"); + fireEvent.pointerDown(card); + act(() => { + vi.advanceTimersByTime(450); + }); + fireEvent.pointerUp(card); + + const sheet = screen.getByRole("dialog", { name: "Asset actions" }); + expect(sheet).toHaveTextContent("Drill #47"); + + // The click that ends the long press must not navigate. + fireEvent.click(card); + expect(pushSpy).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("a short press does not open the actions sheet", () => { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + vi.setSystemTime(NOW); + try { + renderList([makeRow()]); + const card = screen.getByTestId("asset-card-a1"); + fireEvent.pointerDown(card); + act(() => { + vi.advanceTimersByTime(200); + }); + fireEvent.pointerUp(card); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect( + screen.queryByRole("dialog", { name: "Asset actions" }), + ).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("opens the actions sheet on right-click", () => { + renderList([makeRow()]); + fireEvent.contextMenu(screen.getByTestId("asset-card-a1")); + expect( + screen.getByRole("dialog", { name: "Asset actions" }), + ).toHaveTextContent("Drill #47"); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(screen.queryByRole("dialog", { name: "Asset actions" })).toBeNull(); }); }); diff --git a/src/__tests__/components/assets/asset-create-page-client.test.tsx b/src/__tests__/components/assets/asset-create-page-client.test.tsx index 4dbde8b..5751159 100644 --- a/src/__tests__/components/assets/asset-create-page-client.test.tsx +++ b/src/__tests__/components/assets/asset-create-page-client.test.tsx @@ -26,6 +26,7 @@ vi.mock("@/lib/api/service-schedules", () => ({ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: mockPush }), + useSearchParams: () => new URLSearchParams(), })); vi.mock("@/components/assets/asset-form", () => ({ diff --git a/src/__tests__/components/assets/asset-detail.test.tsx b/src/__tests__/components/assets/asset-detail.test.tsx index 7ed06ed..84a69ca 100644 --- a/src/__tests__/components/assets/asset-detail.test.tsx +++ b/src/__tests__/components/assets/asset-detail.test.tsx @@ -1,14 +1,45 @@ -import { describe, expect, it, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { AssetDetail } from "@/components/assets/asset-detail"; import type { AssetDetail as AssetDetailType } from "@/components/assets/types"; import type { FieldDefinition } from "@/components/fields/types"; +const listAttachmentsMock = vi.fn(); +const listLocationsMock = vi.fn(); +const nextServiceSpy = vi.fn(); +const labelPanelSpy = vi.fn(); + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + ...props + }: React.AnchorHTMLAttributes & { href: string }) => ( + + {children} + + ), +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/assets/asset1", + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + vi.mock("@/lib/use-app-date-format", () => ({ useAppDateFormat: () => "DD-MM-YYYY", })); +vi.mock("@/lib/api/attachments", () => ({ + listAttachments: (assetId: string) => listAttachmentsMock(assetId), +})); + +vi.mock("@/lib/api/locations", () => ({ + listLocations: () => listLocationsMock(), +})); + vi.mock("@/components/fields/dynamic-field-display", () => ({ DynamicFieldDisplay: ({ definition }: { definition: { name: string } }) => ( field:{definition.name} @@ -20,23 +51,54 @@ vi.mock("@/components/attachments/attachments-panel", () => ({ })); vi.mock("@/components/services/asset-service-records-panel", () => ({ - AssetServiceRecordsPanel: () =>
service-records-panel
, + AssetServiceRecordsPanel: ({ assetId }: { assetId: string }) => ( +
service-records-panel:{assetId}
+ ), })); +vi.mock("@/components/services/next-service-panel", () => ({ + NextServicePanel: (props: { + assetId: string; + assetName: string; + serviceGroupName: string | null; + }) => { + nextServiceSpy(props); + return
next-service-panel
; + }, +})); + +vi.mock("@/components/labels/asset-label-panel", () => ({ + AssetLabelPanel: (props: { asset: { assetTag: string } }) => { + labelPanelSpy(props); + return
asset-label-panel
; + }, +})); + +vi.mock("@/components/assets/asset-photo-slot", () => ({ + AssetPhotoSlot: ({ assetName }: { assetName: string }) => ( +
photo-slot:{assetName}
+ ), +})); + +import { AssetDetail } from "@/components/assets/asset-detail"; + +const DAY = 24 * 60 * 60 * 1000; +const NOW = new Date(2026, 10, 6, 12, 0, 0).getTime(); + const baseAsset: AssetDetailType = { id: "asset1", name: "Core Router", assetTag: "IT-0042", status: "active", categoryId: "cat1", - locationId: "loc1", + locationId: "loc2", serviceGroupId: null, notes: "Main network router", customFieldValues: { field1: "SN-12345" }, createdBy: "user1", updatedBy: "user1", - createdAt: 1700000000000, - updatedAt: 1700000000000, + createdAt: new Date(2026, 0, 15, 12).getTime(), + updatedAt: NOW - 2 * DAY, category: { id: "cat1", name: "Networking", @@ -44,9 +106,9 @@ const baseAsset: AssetDetailType = { color: "#3b82f6", }, location: { - id: "loc1", + id: "loc2", name: "Server Room", - parentId: null, + parentId: "loc1", path: "Building A / Server Room", }, serviceGroup: null, @@ -82,100 +144,319 @@ const fieldDefinitions: FieldDefinition[] = [ }, ]; -const defaultProps = { - asset: baseAsset, - fieldDefinitions, - canDelete: true, - deleting: false, - updatingStatus: false, - onStatusChange: vi.fn(), - onDelete: vi.fn().mockResolvedValue(undefined), -}; +function makeProps(overrides: Partial> = {}) { + return { + asset: baseAsset, + fieldDefinitions, + canDelete: true, + deleting: false, + updatingStatus: false, + onStatusChange: vi.fn(), + onDelete: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function renderDetail(props = makeProps()) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + , + ); +} -function renderWithProviders(ui: React.ReactElement) { - return render({ui}); +async function openMoreActions() { + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "More actions" })); + await screen.findByRole("menu"); + return user; } describe("AssetDetail", () => { - it("renders basic asset information", () => { - renderWithProviders(); + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + listAttachmentsMock.mockReset(); + listAttachmentsMock.mockResolvedValue([]); + listLocationsMock.mockReset(); + listLocationsMock.mockResolvedValue([]); + nextServiceSpy.mockReset(); + labelPanelSpy.mockReset(); + }); - expect(screen.getByText("Core Router")).toBeInTheDocument(); - expect(screen.getByText("IT-0042")).toBeInTheDocument(); - expect(screen.getByText("Networking")).toBeInTheDocument(); - expect(screen.getByText("Building A / Server Room")).toBeInTheDocument(); + afterEach(() => { + vi.useRealTimers(); + document.getElementById("mobile-top-bar-slot")?.remove(); }); - it("renders tags when present", () => { - renderWithProviders(); + describe("hero", () => { + it("shows the tag, name, status, location, category and keywords", () => { + renderDetail(); - expect(screen.getByText("Critical")).toBeInTheDocument(); - expect(screen.getByText("Production")).toBeInTheDocument(); - }); + expect( + screen.getByRole("heading", { level: 1, name: "Core Router" }), + ).toBeInTheDocument(); + expect(screen.getByTestId("asset-tag")).toHaveTextContent("IT-0042"); + expect( + screen.getByRole("combobox", { name: "Change status" }), + ).toHaveTextContent("Active"); + expect(screen.getByText("Building A / Server Room")).toBeInTheDocument(); + // Category sits in the status row on desktop and the keyword row below. + expect(screen.getAllByText("Networking")).toHaveLength(2); + expect(screen.getByText("Critical")).toBeInTheDocument(); + expect(screen.getByText("Production")).toBeInTheDocument(); + expect(screen.getByText("photo-slot:Core Router")).toBeInTheDocument(); + }); - it("renders notes", () => { - renderWithProviders(); + it("links back to Assets", () => { + renderDetail(); + expect(screen.getByRole("link", { name: "Assets" })).toHaveAttribute( + "href", + "/assets", + ); + }); - expect(screen.getByText("Main network router")).toBeInTheDocument(); - }); + it("turns the location into a picker run when it can be changed", async () => { + listLocationsMock.mockResolvedValue([ + { id: "loc1", name: "Building A", parentId: null, path: "Building A" }, + { + id: "loc2", + name: "Server Room", + parentId: "loc1", + path: "Building A / Server Room", + }, + ]); + renderDetail(makeProps({ onLocationChange: vi.fn() })); - it("shows 'No notes' when notes are null", () => { - renderWithProviders( - , - ); + expect( + await screen.findByRole("button", { + name: "Location: Building A / Server Room. Change location", + }), + ).toBeInTheDocument(); + }); - expect(screen.getByText("No notes")).toBeInTheDocument(); - }); + it("keeps showing the current location while the location list loads", () => { + listLocationsMock.mockReturnValue(new Promise(() => {})); + renderDetail(makeProps({ onLocationChange: vi.fn() })); - it("renders custom fields", () => { - renderWithProviders(); + expect(screen.queryByText("No locations available.")).toBeNull(); + expect( + screen.getByRole("button", { + name: "Location: Building A / Server Room. Change location", + }), + ).toBeInTheDocument(); + }); - expect(screen.getByText("Serial Number")).toBeInTheDocument(); - expect(screen.getByText("field:Serial Number")).toBeInTheDocument(); - }); + it("says No location in the picker run when the asset has none", async () => { + listLocationsMock.mockResolvedValue([ + { id: "loc1", name: "Building A", parentId: null, path: "Building A" }, + ]); + renderDetail( + makeProps({ + asset: { ...baseAsset, location: null, locationId: null }, + onLocationChange: vi.fn(), + }), + ); - it("hides delete button when canDelete is false", () => { - renderWithProviders(); + const run = await screen.findByRole("button", { + name: "Location: none. Change location", + }); + expect(run).toHaveTextContent("No location"); + }); - expect( - screen.queryByRole("button", { name: /delete/i }), - ).not.toBeInTheDocument(); + it("shows no location run when the location is read-only and unset", () => { + renderDetail( + makeProps({ asset: { ...baseAsset, location: null, locationId: null } }), + ); + expect(screen.queryByText("Building A / Server Room")).toBeNull(); + expect(screen.queryByRole("button", { name: /Change location/ })).toBeNull(); + }); }); - it("shows edit and print label links", () => { - renderWithProviders(); + describe("actions", () => { + it("shows an Edit link", () => { + renderDetail(); + expect(screen.getByRole("link", { name: "Edit" })).toHaveAttribute( + "href", + "/assets/asset1/edit", + ); + }); + + it("collects Edit, Print label and Delete… in the More actions menu", async () => { + renderDetail(); + await openMoreActions(); + + const menu = screen.getByRole("menu"); + expect( + within(menu).getByRole("menuitem", { name: "Edit" }), + ).toHaveAttribute("href", "/assets/asset1/edit"); + expect( + within(menu).getByRole("menuitem", { name: "Print label" }), + ).toHaveAttribute("href", "/labels/print?assetId=asset1"); + expect( + within(menu).getByRole("menuitem", { name: "Delete…" }), + ).toBeInTheDocument(); + }); + + it("leaves Delete… out of the menu when the user cannot delete", async () => { + renderDetail(makeProps({ canDelete: false })); + await openMoreActions(); + + expect(screen.getByRole("menuitem", { name: "Print label" })).toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: /Delete/ })).toBeNull(); + expect(screen.queryByTestId("asset-detail-delete")).toBeNull(); + }); + + it("confirms before deleting", async () => { + const props = makeProps(); + renderDetail(props); + const user = await openMoreActions(); + + await user.click(screen.getByRole("menuitem", { name: "Delete…" })); + const dialog = await screen.findByRole("dialog", { name: "Delete asset?" }); + expect( + within(dialog).getByText( + "IT-0042 Core Router and its service history are deleted.", + ), + ).toBeInTheDocument(); + expect(props.onDelete).not.toHaveBeenCalled(); + + await user.click( + within(dialog).getByRole("button", { name: "Delete asset" }), + ); + expect(props.onDelete).toHaveBeenCalledOnce(); + }); - expect(screen.getByText("Edit")).toBeInTheDocument(); - expect(screen.getByText("Print label")).toBeInTheDocument(); + it("changes status from the status picker run", async () => { + const user = userEvent.setup(); + const props = makeProps(); + renderDetail(props); + + await user.click(screen.getByRole("combobox", { name: "Change status" })); + await user.click(await screen.findByRole("option", { name: "Under repair" })); + expect(props.onStatusChange).toHaveBeenCalledWith("under_repair"); + }); }); - it("renders a mobile kebab menu that collapses Edit / Print / Delete", async () => { - renderWithProviders(); - const kebab = screen.getByTestId("asset-detail-kebab"); - expect(kebab).toBeInTheDocument(); - expect(kebab.className).toMatch(/lg:hidden/); - - fireEvent.pointerDown(kebab, { pointerType: "mouse", button: 0 }); - fireEvent.pointerUp(kebab, { pointerType: "mouse", button: 0 }); - fireEvent.click(kebab); - const deleteItem = await screen.findByTestId("asset-detail-delete"); - expect(deleteItem).toBeInTheDocument(); + describe("details card", () => { + it("renders custom fields", () => { + renderDetail(); + expect(screen.getByText("Serial Number")).toBeInTheDocument(); + expect(screen.getByText("field:Serial Number")).toBeInTheDocument(); + }); + + it("shows the service plan when the asset has one", () => { + renderDetail( + makeProps({ + asset: { + ...baseAsset, + serviceGroupId: "sg1", + serviceGroup: { id: "sg1", name: "Quarterly check" } as never, + }, + }), + ); + expect(screen.getByText("Service plan")).toBeInTheDocument(); + expect(screen.getByText("Quarterly check")).toBeInTheDocument(); + expect(nextServiceSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ serviceGroupName: "Quarterly check" }), + ); + }); + + it("renders notes", () => { + renderDetail(); + expect(screen.getByText("Main network router")).toBeInTheDocument(); + }); + + it("shows 'No notes' when notes are null", () => { + renderDetail(makeProps({ asset: { ...baseAsset, notes: null } })); + expect(screen.getByText("No notes")).toBeInTheDocument(); + }); + + it("counts attachments on the tab and shows them when selected", async () => { + listAttachmentsMock.mockResolvedValue([{ id: "f1" }, { id: "f2" }]); + const user = userEvent.setup(); + renderDetail(); + + const tab = screen.getByRole("tab", { name: /Attachments/ }); + await waitFor(() => expect(tab).toHaveTextContent("Attachments2")); + expect(listAttachmentsMock).toHaveBeenCalledWith("asset1"); + + await user.click(tab); + expect(await screen.findByText("attachments-panel")).toBeInTheDocument(); + }); + + it("shows created and last-changed dates", () => { + renderDetail(); + expect(screen.getByText(/Created 15-01-2026\./)).toBeInTheDocument(); + expect(screen.getByText("2 days ago")).toBeInTheDocument(); + expect(screen.getByText("2d")).toBeInTheDocument(); + }); }); - it("hides the kebab delete entry when canDelete is false", async () => { - renderWithProviders(); - const kebab = screen.getByTestId("asset-detail-kebab"); - fireEvent.pointerDown(kebab, { pointerType: "mouse", button: 0 }); - fireEvent.pointerUp(kebab, { pointerType: "mouse", button: 0 }); - fireEvent.click(kebab); - // Wait a microtask to let the menu render - await Promise.resolve(); - expect(screen.queryByTestId("asset-detail-delete")).toBeNull(); + describe("panels", () => { + it("renders Next service, service history and the label panel for the asset", () => { + renderDetail(); + expect(screen.getByText("next-service-panel")).toBeInTheDocument(); + expect(screen.getByText("service-records-panel:asset1")).toBeInTheDocument(); + expect(screen.getByText("asset-label-panel")).toBeInTheDocument(); + + expect(nextServiceSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + assetId: "asset1", + assetName: "Core Router", + serviceGroupName: null, + }), + ); + expect(labelPanelSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + asset: expect.objectContaining({ + id: "asset1", + assetTag: "IT-0042", + categoryName: "Networking", + locationPath: "Building A / Server Room", + }), + }), + ); + }); }); - it("makes the detail tabs sticky on mobile", () => { - renderWithProviders(); - const tabs = screen.getByTestId("asset-detail-tabs"); - expect(tabs.className).toMatch(/sticky/); + describe("phone top bar", () => { + it("renders nothing into the phone bar when the slot is absent", () => { + renderDetail(); + expect( + screen.queryByRole("link", { name: "Back to Assets" }), + ).toBeNull(); + expect(screen.queryByTestId("asset-detail-kebab")).toBeNull(); + }); + + it("replaces the phone top bar with back, tag, edit and actions", async () => { + const slot = document.createElement("div"); + slot.id = "mobile-top-bar-slot"; + document.body.appendChild(slot); + + renderDetail(); + + const bar = within(slot); + expect( + await bar.findByRole("link", { name: "Back to Assets" }), + ).toHaveAttribute("href", "/assets"); + expect(bar.getByText("IT-0042")).toBeInTheDocument(); + expect(bar.getByRole("link", { name: "Edit asset" })).toHaveAttribute( + "href", + "/assets/asset1/edit", + ); + + const user = userEvent.setup(); + await user.click(bar.getByRole("button", { name: "Asset actions" })); + expect( + await screen.findByRole("menuitem", { name: "Print label" }), + ).toBeInTheDocument(); + expect(screen.getByTestId("asset-detail-delete")).toBeInTheDocument(); + }); }); }); diff --git a/src/__tests__/components/assets/asset-filters-mobile.test.tsx b/src/__tests__/components/assets/asset-filters-mobile.test.tsx index b4bbd54..e6b7eb0 100644 --- a/src/__tests__/components/assets/asset-filters-mobile.test.tsx +++ b/src/__tests__/components/assets/asset-filters-mobile.test.tsx @@ -94,15 +94,68 @@ function renderFilters(overrides: Partial = {}) { } describe("AssetFiltersMobile", () => { - it("renders a search input and a filters trigger chip", () => { + it("renders a status select and a filters trigger in the row, with search inside the sheet", () => { renderFilters(); + const status = screen.getByRole("combobox", { name: "Filter by status" }); + expect(status).toHaveAttribute("data-testid", "asset-filters-mobile-status"); + expect(status).toHaveValue(""); expect( - screen.getByTestId("asset-filters-mobile-search"), - ).toBeInTheDocument(); - expect( - screen.getByTestId("asset-filters-mobile-trigger"), - ).toBeInTheDocument(); + screen.getByRole("button", { name: "Filters" }), + ).toHaveAttribute("data-testid", "asset-filters-mobile-trigger"); expect(screen.queryByTestId("asset-filters-mobile-count")).toBeNull(); + + // Search moved into the sheet. + expect(screen.queryByTestId("asset-filters-mobile-search")).toBeNull(); + fireEvent.click(screen.getByTestId("asset-filters-mobile-trigger")); + expect(screen.getByTestId("asset-filters-mobile-search")).toHaveAccessibleName( + "Search assets", + ); + }); + + it("emits a status change from the row select", () => { + const { handlers } = renderFilters(); + fireEvent.change(screen.getByRole("combobox", { name: "Filter by status" }), { + target: { value: "under_repair" }, + }); + expect(handlers.onFiltersChange).toHaveBeenCalledWith({ + categoryId: null, + status: "under_repair", + locationId: null, + tagIds: [], + }); + }); + + it("clears the status back to All from the row select", () => { + const { handlers } = renderFilters({ + filters: { + categoryId: null, + status: "retired", + locationId: null, + tagIds: [], + }, + }); + const select = screen.getByRole("combobox", { name: "Filter by status" }); + expect(select).toHaveValue("retired"); + fireEvent.change(select, { target: { value: "" } }); + expect(handlers.onFiltersChange).toHaveBeenCalledWith( + expect.objectContaining({ status: null }), + ); + }); + + it("types into the sheet's search field", () => { + const { handlers } = renderFilters(); + fireEvent.click(screen.getByTestId("asset-filters-mobile-trigger")); + fireEvent.change(screen.getByTestId("asset-filters-mobile-search"), { + target: { value: "drill" }, + }); + expect(handlers.onSearchChange).toHaveBeenCalledWith("drill"); + }); + + it("shows the active search under the row with a Clear action", () => { + const { handlers } = renderFilters({ search: "drill" }); + expect(screen.getByText("“drill”")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + expect(handlers.onSearchChange).toHaveBeenCalledWith(""); }); it("shows an active count badge when filters apply", () => { diff --git a/src/__tests__/components/assets/asset-filters.test.tsx b/src/__tests__/components/assets/asset-filters.test.tsx index b731d20..6e27c44 100644 --- a/src/__tests__/components/assets/asset-filters.test.tsx +++ b/src/__tests__/components/assets/asset-filters.test.tsx @@ -96,7 +96,23 @@ describe("AssetFilters", () => { expect(onReset).toHaveBeenCalledOnce(); }); - it("exposes accessible labels for custom location and tag filters", async () => { + it("exposes accessible labels for custom location and keyword filters", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "Location" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Keywords" })).toBeInTheDocument(); + }); + + it("shows the one set of dropdowns when More filters is opened", async () => { const user = userEvent.setup(); render( @@ -110,9 +126,89 @@ describe("AssetFilters", () => { />, ); - await user.click(screen.getByRole("button", { name: "More filters" })); + const more = screen.getByRole("button", { name: "More filters" }); + expect(more).toHaveAttribute("aria-expanded", "false"); + const row = screen.getByRole("button", { name: "Keywords" }).parentElement!; + expect(row).toHaveClass("hidden"); + + await user.click(more); + expect(more).toHaveAttribute("aria-expanded", "true"); + // Rendered once, so ids such as asset-filter-tags stay unique. + expect(screen.getAllByRole("button", { name: "Keywords" })).toHaveLength(1); + expect(row).not.toHaveClass("hidden"); + }); + + it("toggles a keyword from the keywords picker", async () => { + const user = userEvent.setup(); + const onFiltersChange = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Keywords" })); + await user.click(await screen.findByRole("option", { name: /Urgent/ })); + expect(onFiltersChange).toHaveBeenCalledWith({ + ...emptyFilters, + tagIds: ["tag1"], + }); + }); + + it("shows applied filters as removable chips", async () => { + const user = userEvent.setup(); + const onFiltersChange = vi.fn(); + const onSearchChange = vi.fn(); + const filters: AssetFiltersState = { + categoryId: "cat1", + status: null, + locationId: "loc1", + tagIds: ["tag1"], + }; + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Remove category filter" })); + expect(onFiltersChange).toHaveBeenLastCalledWith({ ...filters, categoryId: null }); + + await user.click(screen.getByRole("button", { name: "Remove location filter" })); + expect(onFiltersChange).toHaveBeenLastCalledWith({ ...filters, locationId: null }); + + await user.click(screen.getByRole("button", { name: "Remove keyword filter" })); + expect(onFiltersChange).toHaveBeenLastCalledWith({ ...filters, tagIds: [] }); + + await user.click(screen.getByRole("button", { name: "Remove search filter" })); + expect(onSearchChange).toHaveBeenLastCalledWith(""); + }); + + it("never shows status as a chip, because status has its own tabs", () => { + render( + , + ); - expect(screen.getByLabelText("Location")).toBeInTheDocument(); - expect(screen.getByLabelText("Tags")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Remove status filter" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Reset" })).toBeNull(); }); }); diff --git a/src/__tests__/components/assets/asset-photo-slot.test.tsx b/src/__tests__/components/assets/asset-photo-slot.test.tsx new file mode 100644 index 0000000..bc19385 --- /dev/null +++ b/src/__tests__/components/assets/asset-photo-slot.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; + +const listAttachmentsMock = vi.fn(); + +vi.mock("@/lib/api/attachments", () => ({ + listAttachments: (assetId: string) => listAttachmentsMock(assetId), + uploadAttachment: vi.fn(), +})); + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { AssetPhotoSlot } from "@/components/assets/asset-photo-slot"; + +function image(status: string, url: string | null = null) { + return { id: `att-${status}`, fileKind: "image", status, url }; +} + +function renderSlot() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe("AssetPhotoSlot", () => { + beforeEach(() => { + listAttachmentsMock.mockReset(); + }); + + it("offers Add photo… when the asset has no image", async () => { + listAttachmentsMock.mockResolvedValue([]); + renderSlot(); + await waitFor(() => + expect(screen.getByRole("button", { name: "Add photo…" })).toBeEnabled(), + ); + }); + + it("shows the first ready image", async () => { + listAttachmentsMock.mockResolvedValue([ + image("ready", "https://pb/img.jpg"), + ]); + renderSlot(); + const img = await screen.findByRole("img", { name: "Drill #47" }); + expect(img).toHaveAttribute("src", "https://pb/img.jpg"); + }); + + it("says Processing photo… while an upload is optimised, then shows it once ready", async () => { + listAttachmentsMock + .mockResolvedValueOnce([image("pending")]) + .mockResolvedValue([image("ready", "https://pb/img.jpg")]); + renderSlot(); + + const button = await screen.findByRole("button", { + name: "Processing photo…", + }); + expect(button).toBeDisabled(); + + // The slot polls on its own while the image is pending. + expect( + await screen.findByRole("img", { name: "Drill #47" }, { timeout: 4000 }), + ).toBeInTheDocument(); + expect(listAttachmentsMock.mock.calls.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/src/__tests__/components/assets/assets-page-client.test.tsx b/src/__tests__/components/assets/assets-page-client.test.tsx index 6fa6a29..42a4837 100644 --- a/src/__tests__/components/assets/assets-page-client.test.tsx +++ b/src/__tests__/components/assets/assets-page-client.test.tsx @@ -1,10 +1,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const listAssetsMock = vi.fn(); const getFilterOptionsMock = vi.fn(); +const getOverviewMock = vi.fn(); const mockPush = vi.fn(); +const isPhoneMock = vi.fn(() => false); +const tableProps = vi.fn(); +const cardListProps = vi.fn(); let currentSearchParams = new URLSearchParams(); vi.mock("@/lib/api/assets", () => ({ @@ -12,21 +16,100 @@ vi.mock("@/lib/api/assets", () => ({ getAssetFilterOptions: () => getFilterOptionsMock(), })); +vi.mock("@/lib/api/dashboard", () => ({ + getDashboardOverview: () => getOverviewMock(), +})); + +vi.mock("@/hooks/use-media-query", () => ({ + useMediaQuery: () => isPhoneMock(), +})); + vi.mock("next/navigation", () => ({ useRouter: () => ({ push: mockPush }), useSearchParams: () => currentSearchParams, })); -vi.mock("@/components/assets/asset-filters", () => ({ - AssetFilters: () =>
filters
, +vi.mock("next/link", () => ({ + default: ({ + children, + href, + ...props + }: React.AnchorHTMLAttributes & { href: string }) => ( + + {children} + + ), +})); + +vi.mock("@/components/assets/asset-filters", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + AssetFilters: () =>
desktop-filters
, + }; +}); + +vi.mock("@/components/assets/asset-filters-mobile", () => ({ + AssetFiltersMobile: () =>
mobile-filters
, })); vi.mock("@/components/assets/asset-table", () => ({ - AssetTable: () =>
table
, + AssetTable: (props: { rows: { id: string }[] }) => { + tableProps(props); + return
table:{props.rows.length}
; + }, +})); + +vi.mock("@/components/assets/asset-card-list", () => ({ + AssetCardList: (props: { rows: { id: string }[] }) => { + cardListProps(props); + return
cards:{props.rows.length}
; + }, })); import { AssetsPageClient } from "@/components/assets/assets-page-client"; +const overview = { + dateFormat: "DD-MM-YYYY", + totalAssets: 21, + statusCounts: { + active: 10, + in_storage: 5, + under_repair: 3, + retired: 2, + disposed: 1, + }, + recentAssets: [], + changedLastDayCount: 0, + categoryBreakdown: [], + locationBreakdown: [], + serviceSchedulingEnabled: true, + overdueServiceCount: 2, + scheduledServiceCount: 4, + upcomingServices: [], +}; + +function makeRow(id: string) { + return { + id, + name: `Asset ${id}`, + assetTag: `AS-${id}`, + status: "active", + categoryId: null, + categoryName: null, + categoryColor: null, + locationId: null, + locationPath: null, + serviceGroupId: null, + notes: null, + tagIds: [], + tagNames: [], + createdAt: 0, + updatedAt: 0, + }; +} + function renderWithClient(ui: React.ReactElement) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -47,10 +130,16 @@ describe("AssetsPageClient", () => { beforeEach(() => { listAssetsMock.mockReset(); getFilterOptionsMock.mockReset(); + getOverviewMock.mockReset(); mockPush.mockReset(); + tableProps.mockReset(); + cardListProps.mockReset(); + isPhoneMock.mockReturnValue(false); + window.localStorage.clear(); currentSearchParams = new URLSearchParams("category=cat1"); listAssetsMock.mockResolvedValue([]); + getOverviewMock.mockResolvedValue(overview); getFilterOptionsMock.mockResolvedValue({ categories: [], locations: [], @@ -82,4 +171,139 @@ describe("AssetsPageClient", () => { }); }); }); + + it("sorts by most recently updated by default", async () => { + renderWithClient(); + + await waitFor(() => expect(listAssetsMock).toHaveBeenCalled()); + expect(listAssetsMock.mock.calls[0]?.[0]).toMatchObject({ + sortBy: "updatedAt", + sortDirection: "desc", + }); + }); + + it("shows the total and a status subline from the dashboard overview", async () => { + renderWithClient(); + + expect( + await screen.findByRole("heading", { level: 1, name: /^Assets\s*21$/ }), + ).toBeInTheDocument(); + expect( + screen.getByText("10 active · 3 under repair · 2 overdue"), + ).toBeInTheDocument(); + expect(getOverviewMock).toHaveBeenCalled(); + }); + + it("renders status tabs with counts and filters by the chosen tab", async () => { + currentSearchParams = new URLSearchParams(); + renderWithClient(); + + const tabs = screen.getByRole("navigation", { name: "Status" }); + const all = within(tabs).getByRole("button", { name: "All" }); + expect(all).toHaveAttribute("aria-current", "page"); + + const repair = await within(tabs).findByRole("button", { + name: /^Under repair\s*3$/, + }); + for (const name of [/^Active\s*10$/, /^In storage\s*5$/, /^Retired\s*2$/, /^Disposed\s*1$/]) { + expect(within(tabs).getByRole("button", { name })).toBeInTheDocument(); + } + + fireEvent.click(repair); + + await waitFor(() => { + expect(listAssetsMock.mock.calls.at(-1)?.[0]).toMatchObject({ + status: "under_repair", + }); + }); + expect(repair).toHaveAttribute("aria-current", "page"); + expect(all.getAttribute("aria-current")).toBeNull(); + }); + + it("selects the status tab from the URL", async () => { + currentSearchParams = new URLSearchParams("status=retired"); + renderWithClient(); + + const tabs = screen.getByRole("navigation", { name: "Status" }); + expect( + within(tabs).getByRole("button", { name: /^Retired/ }), + ).toHaveAttribute("aria-current", "page"); + await waitFor(() => expect(listAssetsMock).toHaveBeenCalled()); + expect(listAssetsMock.mock.calls[0]?.[0]).toMatchObject({ + status: "retired", + }); + }); + + it("ignores an unknown status in the URL", async () => { + currentSearchParams = new URLSearchParams("status=bogus"); + renderWithClient(); + + await waitFor(() => expect(listAssetsMock).toHaveBeenCalled()); + expect(listAssetsMock.mock.calls[0]?.[0]).toMatchObject({ + status: undefined, + }); + }); + + it("offers New asset… and Print labels… in the header", () => { + renderWithClient(); + + expect(screen.getByRole("link", { name: "New asset…" })).toHaveAttribute( + "href", + "/assets/new", + ); + expect(screen.getByRole("link", { name: "Print labels…" })).toHaveAttribute( + "href", + "/labels/print", + ); + }); + + it("shows the first-run empty state when there are no assets and no filters", async () => { + currentSearchParams = new URLSearchParams(); + renderWithClient(); + + expect(await screen.findByText("No assets yet.")).toBeInTheDocument(); + expect(screen.queryByText(/^table:/)).toBeNull(); + }); + + it("renders the table with desktop filters on wider screens", async () => { + listAssetsMock.mockResolvedValue([makeRow("a1"), makeRow("a2")]); + renderWithClient(); + + expect(screen.getByText("desktop-filters")).toBeInTheDocument(); + expect(await screen.findByText("table:2")).toBeInTheDocument(); + expect(screen.getByText("1 to 2 of 2")).toBeInTheDocument(); + expect(screen.queryByText("mobile-filters")).toBeNull(); + }); + + it("renders cards, mobile filters and the long-press hint on phones", async () => { + isPhoneMock.mockReturnValue(true); + listAssetsMock.mockResolvedValue([makeRow("a1"), makeRow("a2")]); + renderWithClient(); + + expect(screen.getByText("mobile-filters")).toBeInTheDocument(); + expect(await screen.findByText("cards:2")).toBeInTheDocument(); + expect(screen.queryByText("desktop-filters")).toBeNull(); + + expect( + screen.getByText("Press and hold an asset for quick actions."), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + expect( + screen.queryByText("Press and hold an asset for quick actions."), + ).toBeNull(); + expect(window.localStorage.getItem("stowage:hint-long-press")).toBe("1"); + }); + + it("pages phone results with Show more", async () => { + isPhoneMock.mockReturnValue(true); + listAssetsMock.mockResolvedValue( + Array.from({ length: 20 }, (_, i) => makeRow(`a${i}`)), + ); + renderWithClient(); + + expect(await screen.findByText("cards:15")).toBeInTheDocument(); + expect(screen.getByText("1 to 15 of 20")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Show 5 more" })); + expect(await screen.findByText("cards:20")).toBeInTheDocument(); + }); }); diff --git a/src/__tests__/components/catalog/taxonomy-manager.test.tsx b/src/__tests__/components/catalog/taxonomy-manager.test.tsx index 2e8eb0d..07bac5f 100644 --- a/src/__tests__/components/catalog/taxonomy-manager.test.tsx +++ b/src/__tests__/components/catalog/taxonomy-manager.test.tsx @@ -76,11 +76,11 @@ describe("TaxonomyManager (categories)", () => { ); expect( - screen.getByRole("heading", { name: "Categories" }), + screen.getByRole("heading", { name: /^Categories/ }), ).toBeInTheDocument(); expect(screen.getByText("Laptops")).toBeInTheDocument(); expect(screen.getByText("Monitors")).toBeInTheDocument(); - expect(screen.getByText("Prefix: LAP")).toBeInTheDocument(); + expect(screen.getByText("LAP")).toBeInTheDocument(); expect(screen.getByText("Portable computers")).toBeInTheDocument(); }); @@ -114,14 +114,14 @@ describe("TaxonomyManager (categories)", () => { ); expect( - screen.queryByRole("button", { name: /add category/i }), + screen.queryByRole("button", { name: /new category/i }), ).not.toBeInTheDocument(); expect( screen.getByText("Only admins can add, edit, or delete categories."), ).toBeInTheDocument(); }); - it("opens create modal on Add Category click", async () => { + it("opens create modal on New category click", async () => { const user = userEvent.setup(); render( @@ -136,11 +136,11 @@ describe("TaxonomyManager (categories)", () => { />, ); - await user.click(screen.getByRole("button", { name: /add category/i })); + await user.click(screen.getByRole("button", { name: /new category/i })); expect(screen.getByRole("dialog")).toBeInTheDocument(); expect( - screen.getByRole("heading", { name: "Add Category" }), + screen.getByRole("heading", { name: "New category" }), ).toBeInTheDocument(); expect(screen.getByLabelText("Name")).toBeInTheDocument(); }); @@ -160,7 +160,9 @@ describe("TaxonomyManager (tags)", () => { />, ); - expect(screen.getByRole("heading", { name: "Tags" })).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: /^Keywords/ }), + ).toBeInTheDocument(); expect(screen.getByText("Fragile")).toBeInTheDocument(); expect(screen.getByText("Heavy")).toBeInTheDocument(); }); @@ -178,6 +180,6 @@ describe("TaxonomyManager (tags)", () => { />, ); - expect(screen.getByText("No tags yet.")).toBeInTheDocument(); + expect(screen.getByText("No keywords yet.")).toBeInTheDocument(); }); }); diff --git a/src/__tests__/components/crud/color-field.test.tsx b/src/__tests__/components/crud/color-field.test.tsx deleted file mode 100644 index 922a3a3..0000000 --- a/src/__tests__/components/crud/color-field.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { ColorField, PRESET_COLORS } from "@/components/crud/color-field"; - -describe("ColorField", () => { - it("renders label, input with initial value, and preset swatches", () => { - render(); - - expect(screen.getByLabelText("Color")).toBeInTheDocument(); - expect(screen.getByDisplayValue("#EA580C")).toBeInTheDocument(); - expect( - screen.getByRole("list", { name: "Preset colors" }), - ).toBeInTheDocument(); - - const swatches = screen.getAllByRole("listitem"); - expect(swatches).toHaveLength(PRESET_COLORS.length); - }); - - it("calls onChange when a preset swatch is clicked", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - - render(); - - await user.click( - screen.getByRole("listitem", { name: `Select ${PRESET_COLORS[3]}` }), - ); - expect(onChange).toHaveBeenCalledWith(PRESET_COLORS[3]); - }); - - it("renders custom label", () => { - render( - , - ); - - expect(screen.getByLabelText("Category color")).toBeInTheDocument(); - }); -}); diff --git a/src/__tests__/components/dashboard/category-breakdown.test.tsx b/src/__tests__/components/dashboard/category-breakdown.test.tsx index 42370b8..6460fb5 100644 --- a/src/__tests__/components/dashboard/category-breakdown.test.tsx +++ b/src/__tests__/components/dashboard/category-breakdown.test.tsx @@ -20,24 +20,35 @@ const items = [ ]; describe("CategoryBreakdown", () => { - it("renders category names and counts", () => { + it("renders the heading, category names and counts", () => { render(); - expect(screen.getByText("By Category")).toBeVisible(); + expect( + screen.getByRole("region", { name: "Assets by category" }), + ).toBeInTheDocument(); expect(screen.getByText("Electronics")).toBeVisible(); expect(screen.getByText("15")).toBeVisible(); expect(screen.getByText("Furniture")).toBeVisible(); expect(screen.getByText("8")).toBeVisible(); }); + it("links to the taxonomy page from the heading", () => { + render(); + + expect(screen.getByRole("link", { name: "Open Taxonomy" })).toHaveAttribute( + "href", + "/taxonomy", + ); + }); + it("links each category to filtered assets page", () => { render(); - expect(screen.getByText("Electronics").closest("a")).toHaveAttribute( + expect(screen.getByRole("link", { name: /Electronics/ })).toHaveAttribute( "href", "/assets?category=cat1", ); - expect(screen.getByText("Furniture").closest("a")).toHaveAttribute( + expect(screen.getByRole("link", { name: /Furniture/ })).toHaveAttribute( "href", "/assets?category=cat2", ); diff --git a/src/__tests__/components/dashboard/dashboard-stats-bar.test.tsx b/src/__tests__/components/dashboard/dashboard-stats-bar.test.tsx index 311d421..c6e3b4d 100644 --- a/src/__tests__/components/dashboard/dashboard-stats-bar.test.tsx +++ b/src/__tests__/components/dashboard/dashboard-stats-bar.test.tsx @@ -1,7 +1,19 @@ -import { describe, expect, it } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; import { DashboardStatsBar } from "@/components/dashboard/dashboard-stats-bar"; +vi.mock("next/link", () => ({ + default: ({ + children, + href, + ...props + }: React.AnchorHTMLAttributes & { href: string }) => ( + + {children} + + ), +})); + const statusCounts = { active: 10, in_storage: 5, @@ -11,64 +23,75 @@ const statusCounts = { }; describe("DashboardStatsBar", () => { - it("renders all 6 stat cards with correct labels and values", () => { - render(); - - expect(screen.getByText("Total Assets")).toBeVisible(); - expect(screen.getByText("21")).toBeVisible(); - - expect(screen.getByText("Active")).toBeVisible(); - expect(screen.getByText("10")).toBeVisible(); + it("renders five cells: assets, overdue, under repair, in storage, retired", () => { + render( + , + ); - expect(screen.getByText("In Storage")).toBeVisible(); - expect(screen.getByText("5")).toBeVisible(); - - expect(screen.getByText("Under Repair")).toBeVisible(); - expect(screen.getByText("3")).toBeVisible(); - - expect(screen.getByText("Retired")).toBeVisible(); - expect(screen.getByText("2")).toBeVisible(); + const bar = screen.getByTestId("dashboard-stats-bar"); + const links = within(bar).getAllByRole("link"); + expect(links).toHaveLength(5); - expect(screen.getByText("Disposed")).toBeVisible(); - expect(screen.getByText("1")).toBeVisible(); + const expected: Array<[RegExp, string, string]> = [ + [/Assets/, "21", "/assets"], + [/Overdue/, "4", "/services"], + [/Under repair/, "3", "/assets?status=under_repair"], + [/In storage/, "5", "/assets?status=in_storage"], + [/Retired/, "2", "/assets?status=retired"], + ]; + expected.forEach(([name, value, href], index) => { + const link = links[index]; + expect(link).toHaveAccessibleName(name); + expect(link).toHaveAttribute("href", href); + expect(within(link).getByText(value)).toBeInTheDocument(); + }); }); - it("renders zero counts correctly", () => { - const zeroCounts = { - active: 0, - in_storage: 0, - under_repair: 0, - retired: 0, - disposed: 0, - }; - - render(); + it("does not show active or disposed counts", () => { + render( + , + ); - const zeros = screen.getAllByText("0"); - expect(zeros).toHaveLength(6); + expect(screen.queryByText("Active")).not.toBeInTheDocument(); + expect(screen.queryByText("Disposed")).not.toBeInTheDocument(); }); - it("renders data-dashboard-stat attributes for each card", () => { - render(); + it("keeps short phone labels next to the full labels", () => { + render( + , + ); - const keys = [ - "total", - "active", - "in_storage", - "under_repair", - "retired", - "disposed", - ]; - for (const key of keys) { - expect( - document.querySelector(`[data-dashboard-stat="${key}"]`), - ).toBeTruthy(); - } + expect(screen.getByText("In repair")).toBeInTheDocument(); + expect(screen.getByText("Stored")).toBeInTheDocument(); }); - it("is horizontally scrollable on mobile and a grid from sm up", () => { - render(); - const bar = screen.getByTestId("dashboard-stats-bar"); - expect(bar).toHaveAttribute("data-layout", "horizontal-scroll-to-grid"); + it("renders zero counts", () => { + render( + , + ); + + expect(screen.getAllByText("0")).toHaveLength(5); }); }); diff --git a/src/__tests__/components/dashboard/location-breakdown.test.tsx b/src/__tests__/components/dashboard/location-breakdown.test.tsx deleted file mode 100644 index e3613bb..0000000 --- a/src/__tests__/components/dashboard/location-breakdown.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { LocationBreakdown } from "@/components/dashboard/location-breakdown"; - -vi.mock("next/link", () => ({ - default: ({ - children, - href, - ...props - }: React.AnchorHTMLAttributes & { href: string }) => ( - - {children} - - ), -})); - -const items = [ - { id: "loc1", name: "Warehouse A", count: 20 }, - { id: "loc2", name: "Office B", count: 5 }, -]; - -describe("LocationBreakdown", () => { - it("renders location names and counts", () => { - render(); - - expect(screen.getByText("By Location")).toBeVisible(); - expect(screen.getByText("Warehouse A")).toBeVisible(); - expect(screen.getByText("20")).toBeVisible(); - expect(screen.getByText("Office B")).toBeVisible(); - expect(screen.getByText("5")).toBeVisible(); - }); - - it("links each location to filtered assets page", () => { - render(); - - expect(screen.getByText("Warehouse A").closest("a")).toHaveAttribute( - "href", - "/assets?location=loc1", - ); - expect(screen.getByText("Office B").closest("a")).toHaveAttribute( - "href", - "/assets?location=loc2", - ); - }); - - it("shows empty state when no locations", () => { - render(); - - expect(screen.getByText("No locations with assets yet.")).toBeVisible(); - }); -}); diff --git a/src/__tests__/components/dashboard/quick-actions.test.tsx b/src/__tests__/components/dashboard/quick-actions.test.tsx deleted file mode 100644 index 66c2b0a..0000000 --- a/src/__tests__/components/dashboard/quick-actions.test.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { QuickActions } from "@/components/dashboard/quick-actions"; - -vi.mock("next/link", () => ({ - default: ({ - children, - href, - ...props - }: React.AnchorHTMLAttributes & { href: string }) => ( - - {children} - - ), -})); - -describe("QuickActions", () => { - it("renders New Asset and View All Assets links", () => { - render(); - - expect(screen.getByText("New Asset")).toBeVisible(); - expect(screen.getByText("View All Assets")).toBeVisible(); - }); - - it("links to correct hrefs", () => { - render(); - - expect(screen.getByText("New Asset").closest("a")).toHaveAttribute( - "href", - "/assets/new", - ); - expect(screen.getByText("View All Assets").closest("a")).toHaveAttribute( - "href", - "/assets", - ); - }); -}); diff --git a/src/__tests__/components/dashboard/recent-assets-card.test.tsx b/src/__tests__/components/dashboard/recent-assets-card.test.tsx index 4ad6618..aff1870 100644 --- a/src/__tests__/components/dashboard/recent-assets-card.test.tsx +++ b/src/__tests__/components/dashboard/recent-assets-card.test.tsx @@ -1,5 +1,5 @@ -import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; import { RecentAssetsCard } from "@/components/dashboard/recent-assets-card"; vi.mock("next/link", () => ({ @@ -14,59 +14,100 @@ vi.mock("next/link", () => ({ ), })); -vi.mock("@/components/assets/status-badge", () => ({ - StatusBadge: ({ status }: { status: string }) => ( - {status} - ), -})); +const NOW = new Date(2026, 10, 6, 12, 0, 0).getTime(); +const DAY = 24 * 60 * 60 * 1000; -const items = [ - { - id: "asset1", - name: "Forklift A", - assetTag: "FK-001", +function makeItem(index: number, overrides: Record = {}) { + return { + id: `asset${index}`, + name: `Asset ${index}`, + assetTag: `AS-00${index}`, status: "active" as const, - updatedAt: 1709251200000, - }, - { - id: "asset2", + updatedAt: NOW - index * DAY, + ...overrides, + }; +} + +const items = [ + makeItem(1, { name: "Forklift A", assetTag: "FK-001" }), + makeItem(2, { name: "Generator B", assetTag: "GN-002", status: "in_storage" as const, - updatedAt: 1709164800000, - }, + }), ]; describe("RecentAssetsCard", () => { - it("renders items with name, tag, and status", () => { - render(); - - expect(screen.getByText("Forklift A")).toBeVisible(); - expect(screen.getByText("FK-001")).toBeVisible(); - expect(screen.getByText("Generator B")).toBeVisible(); - expect(screen.getByText("GN-002")).toBeVisible(); - expect(screen.getAllByTestId("status-badge")).toHaveLength(2); + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); }); - it("shows empty state when items is empty", () => { - render(); + afterEach(() => { + vi.useRealTimers(); + }); - expect(screen.getByText("No assets have been added yet.")).toBeVisible(); + it("renders the Recently updated heading", () => { + render(); + + expect( + screen.getByRole("region", { name: "Recently updated" }), + ).toBeInTheDocument(); }); - it("renders 'View all' link pointing to /assets", () => { - render(); + it("links each row to the asset with its name, tag and relative time", () => { + render(); - const link = screen.getByText("View all"); - expect(link).toBeVisible(); - expect(link.closest("a")).toHaveAttribute("href", "/assets"); + const forklift = screen.getByRole("link", { name: /Forklift A/ }); + expect(forklift).toHaveAttribute("href", "/assets/asset1"); + expect(within(forklift).getByText("FK-001")).toBeInTheDocument(); + expect(within(forklift).getByText("Yesterday")).toBeInTheDocument(); + expect(within(forklift).getByText("1d")).toBeInTheDocument(); + + const generator = screen.getByRole("link", { name: /Generator B/ }); + expect(generator).toHaveAttribute("href", "/assets/asset2"); + expect(within(generator).getByText("GN-002")).toBeInTheDocument(); + expect(within(generator).getByText("2 days ago")).toBeInTheDocument(); }); - it("renders per-asset 'View' links", () => { - render(); + it("shows status only for exceptions, not for active assets", () => { + render(); - const viewLinks = screen.getAllByText("View"); - expect(viewLinks).toHaveLength(2); - expect(viewLinks[0].closest("a")).toHaveAttribute("href", "/assets/asset1"); + const badges = screen.getAllByTestId("status-badge"); + expect(badges).toHaveLength(1); + expect(badges[0]).toHaveTextContent("In storage"); + expect( + within(screen.getByRole("link", { name: /Forklift A/ })).queryByTestId( + "status-badge", + ), + ).not.toBeInTheDocument(); + }); + + it("closes the list with an Open in Assets link", () => { + render(); + + expect(screen.getByRole("link", { name: "Open in Assets" })).toHaveAttribute( + "href", + "/assets", + ); + expect(screen.queryByText(/of the last/)).not.toBeInTheDocument(); + }); + + it("shows at most five rows and says how many changes were cut", () => { + const many = Array.from({ length: 7 }, (_, i) => makeItem(i + 1)); + render(); + + expect(screen.getAllByRole("listitem")).toHaveLength(5); + expect(screen.queryByText("Asset 6")).not.toBeInTheDocument(); + expect(screen.getByText(/5 of the last 7 changes/)).toBeInTheDocument(); + }); + + it("shows empty state when items is empty", () => { + render(); + + expect(screen.getByText("No assets have been added yet.")).toBeVisible(); + expect( + screen.queryByRole("link", { name: "Open in Assets" }), + ).not.toBeInTheDocument(); }); }); diff --git a/src/__tests__/components/dashboard/stat-card.test.tsx b/src/__tests__/components/dashboard/stat-card.test.tsx deleted file mode 100644 index 9d54963..0000000 --- a/src/__tests__/components/dashboard/stat-card.test.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { Package } from "lucide-react"; -import { StatCard } from "@/components/dashboard/stat-card"; - -describe("StatCard", () => { - it("renders the label and count", () => { - render( - , - ); - - expect(screen.getByText("Total assets")).toBeVisible(); - expect(screen.getByText("42")).toBeVisible(); - }); - - it("renders zero values", () => { - render( - , - ); - - expect(screen.getByText("Disposed")).toBeVisible(); - expect(screen.getByText("0")).toBeVisible(); - }); -}); diff --git a/src/__tests__/components/dashboard/upcoming-services-widget.test.tsx b/src/__tests__/components/dashboard/upcoming-services-widget.test.tsx index d33e52c..f35b4e7 100644 --- a/src/__tests__/components/dashboard/upcoming-services-widget.test.tsx +++ b/src/__tests__/components/dashboard/upcoming-services-widget.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import { UpcomingServicesWidget } from "@/components/dashboard/upcoming-services-widget"; vi.mock("next/link", () => ({ @@ -36,7 +36,7 @@ const items = [ ]; describe("UpcomingServicesWidget", () => { - it("renders items with asset name, tag, and due label", () => { + it("renders the Service due heading", () => { render( { />, ); - expect(screen.getByText("Forklift A")).toBeVisible(); - expect(screen.getByText("FK-001")).toBeVisible(); - expect(screen.getByText("2 days overdue")).toBeVisible(); + expect( + screen.getByRole("region", { name: "Service due" }), + ).toBeInTheDocument(); + }); + + it("links each row to the asset with tag, date and due marker", () => { + render( + , + ); + + const forklift = screen.getByRole("link", { name: /Forklift A/ }); + expect(forklift).toHaveAttribute("href", "/assets/asset1"); + expect(within(forklift).getByText("FK-001")).toBeInTheDocument(); + expect(within(forklift).getByText("05-03-2026")).toBeInTheDocument(); + expect(within(forklift).getByText("Overdue 2 days")).toBeInTheDocument(); + expect(within(forklift).getByText("Overdue 2d")).toBeInTheDocument(); - expect(screen.getByText("Generator B")).toBeVisible(); - expect(screen.getByText("GN-002")).toBeVisible(); - expect(screen.getByText("Due in 3 days")).toBeVisible(); + const generator = screen.getByRole("link", { name: /Generator B/ }); + expect(generator).toHaveAttribute("href", "/assets/asset2"); + expect(within(generator).getByText("GN-002")).toBeInTheDocument(); + expect(within(generator).getByText("10-03-2026")).toBeInTheDocument(); + expect(within(generator).getByText("In 3 days")).toBeInTheDocument(); }); - it("shows overdue badge when overdueCount > 0", () => { + it("closes the list with overdue and more-scheduled counts and a services link", () => { render( , + ); + + expect(screen.getByText(/3 overdue/)).toBeInTheDocument(); + expect(screen.getByText(/4 more scheduled/)).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "See all services" }), + ).toHaveAttribute("href", "/services"); + }); + + it("omits the overdue and more counts when there are none", () => { + render( + , ); - expect(screen.getByText("3 overdue")).toBeVisible(); + expect(screen.queryByText(/overdue ·/)).not.toBeInTheDocument(); + expect(screen.queryByText(/more scheduled/)).not.toBeInTheDocument(); }); it("shows disabled message when service scheduling is off", () => { diff --git a/src/__tests__/components/labels/asset-label-panel.test.tsx b/src/__tests__/components/labels/asset-label-panel.test.tsx new file mode 100644 index 0000000..aa63274 --- /dev/null +++ b/src/__tests__/components/labels/asset-label-panel.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; + +const getDefaultLabelTemplateMock = vi.fn(); +const getLabelUrlBaseMock = vi.fn(); +const ensureDefaultLabelTemplatesMock = vi.fn(); + +vi.mock("@/lib/api/label-templates", () => ({ + getDefaultLabelTemplate: () => getDefaultLabelTemplateMock(), + getLabelUrlBase: () => getLabelUrlBaseMock(), + ensureDefaultLabelTemplates: () => ensureDefaultLabelTemplatesMock(), +})); + +vi.mock("@/hooks/use-current-user", () => ({ + useCurrentUser: () => ({ data: { role: "admin" } }), +})); + +vi.mock("@/components/labels/label-preview", () => ({ + LabelPreview: () =>
label-preview
, +})); + +import { AssetLabelPanel } from "@/components/labels/asset-label-panel"; + +const asset = { + id: "asset1", + name: "Drill #47", + assetTag: "AST-0047", +} as never; + +function renderPanel() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe("AssetLabelPanel", () => { + beforeEach(() => { + getDefaultLabelTemplateMock.mockReset(); + getLabelUrlBaseMock.mockReset().mockResolvedValue(null); + ensureDefaultLabelTemplatesMock.mockReset(); + }); + + it("points to the Labels page without re-creating templates when none exist", async () => { + getDefaultLabelTemplateMock.mockResolvedValue(null); + renderPanel(); + + expect( + await screen.findByText(/No label template yet\./), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Set one up…" })).toHaveAttribute( + "href", + "/labels", + ); + expect(ensureDefaultLabelTemplatesMock).not.toHaveBeenCalled(); + }); + + it("links Print label to the print page for this asset", async () => { + getDefaultLabelTemplateMock.mockResolvedValue(null); + renderPanel(); + + expect(screen.getByRole("link", { name: /Print label/ })).toHaveAttribute( + "href", + "/labels/print?assetId=asset1", + ); + await screen.findByText(/No label template yet\./); + }); +}); diff --git a/src/__tests__/components/labels/element-properties.test.tsx b/src/__tests__/components/labels/element-properties.test.tsx new file mode 100644 index 0000000..b9763b0 --- /dev/null +++ b/src/__tests__/components/labels/element-properties.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { ElementProperties } from "@/components/labels/element-properties"; +import type { EditableLabelTemplate } from "@/components/labels/types"; + +function renderProps( + template: Partial, + onDeleteTemplate = vi.fn(), +) { + render( + , + ); + return onDeleteTemplate; +} + +describe("ElementProperties template footer", () => { + it("offers a named delete button for a non-default template", () => { + const onDelete = renderProps({ isDefault: false }); + fireEvent.click(screen.getByRole("button", { name: "Delete template…" })); + expect(onDelete).toHaveBeenCalledTimes(1); + }); + + it("says in visible text that the default template can't be deleted", () => { + renderProps({ isDefault: true }); + expect( + screen.queryByRole("button", { name: "Delete template…" }), + ).toBeNull(); + expect( + screen.getByText("Default template, can't be deleted"), + ).toBeVisible(); + }); +}); diff --git a/src/__tests__/components/labels/template-designer.test.tsx b/src/__tests__/components/labels/template-designer.test.tsx index 37c6fbc..d8c7158 100644 --- a/src/__tests__/components/labels/template-designer.test.tsx +++ b/src/__tests__/components/labels/template-designer.test.tsx @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import { TemplateDesigner } from "@/components/labels/template-designer"; import type { LabelPreviewAsset, @@ -141,6 +141,35 @@ describe("TemplateDesigner", () => { expect(screen.getByText("57 x 32 mm")).toBeInTheDocument(); }); + it("lists templates as list items holding buttons, marking the active one", () => { + renderWithClient( + , + ); + + const list = screen.getByRole("list", { name: "Label templates" }); + const items = within(list).getAllByRole("listitem"); + expect(items).toHaveLength(2); + const first = within(items[0]).getByRole("button"); + expect(first).toHaveAttribute("aria-current", "true"); + expect(within(items[1]).getByRole("button")).not.toHaveAttribute( + "aria-current", + ); + }); + it("shows empty state when no templates exist", () => { renderWithClient( ({ logout: vi.fn(), })); +vi.mock("@/hooks/use-overdue-count", () => ({ + useOverdueCount: () => 0, +})); + vi.mock("@/components/search/global-search", () => ({ GlobalSearch: () => ( ), + GlobalSearchDialog: () => null, })); import { AppShell } from "@/components/layout/app-shell"; @@ -55,18 +60,23 @@ describe("AppShell", () => { expect(screen.getByText("Hello Stowage")).toBeInTheDocument(); }); - it("renders sidebar and topbar together", () => { + it("renders the sidebar, tablet rail, phone top bar and bottom nav around main", () => { renderWithClient(
Content
, ); - const brands = screen.getAllByText("Stowage"); - expect(brands.length).toBeGreaterThanOrEqual(1); - const triggers = screen.getAllByRole("button", { - name: "Open global search", - }); - expect(triggers.length).toBeGreaterThanOrEqual(1); - expect(screen.getByText("Content")).toBeInTheDocument(); + // Desktop sidebar and tablet rail are both "Main" complementary landmarks; + // CSS decides which one shows. + expect(screen.getAllByRole("complementary", { name: "Main" })).toHaveLength( + 2, + ); + expect(screen.getByRole("banner")).toBeInTheDocument(); + expect(screen.getByRole("navigation", { name: "Primary" })).toBeInTheDocument(); + expect(screen.getByRole("main")).toHaveTextContent("Content"); + // Sidebar, rail and phone top bar each carry a search trigger. + expect( + screen.getAllByRole("button", { name: "Open global search" }), + ).toHaveLength(3); }); }); diff --git a/src/__tests__/components/layout/app-sidebar.test.tsx b/src/__tests__/components/layout/app-sidebar.test.tsx index 08a6ccf..cb8b13c 100644 --- a/src/__tests__/components/layout/app-sidebar.test.tsx +++ b/src/__tests__/components/layout/app-sidebar.test.tsx @@ -1,67 +1,156 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; + +const pathnameSpy = vi.fn(() => "/dashboard"); +const overdueSpy = vi.fn(() => 0); -// Mock next/navigation vi.mock("next/navigation", () => ({ - usePathname: () => "/dashboard", + usePathname: () => pathnameSpy(), +})); + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + ...props + }: React.AnchorHTMLAttributes & { href: string }) => ( + + {children} + + ), +})); + +vi.mock("@/hooks/use-overdue-count", () => ({ + useOverdueCount: () => overdueSpy(), +})); + +vi.mock("@/components/search/global-search", () => ({ + GlobalSearch: ({ variant }: { variant?: string }) => ( + + ), })); -// Mock use-mobile hook -vi.mock("@/hooks/use-mobile", () => ({ - useIsMobile: () => false, +vi.mock("@/components/layout/user-menu", () => ({ + UserMenu: () => ( + + ), })); -import { SidebarProvider } from "@/components/ui/sidebar"; -import { AppSidebar } from "@/components/layout/app-sidebar"; +import { AppRail, AppSidebar } from "@/components/layout/app-sidebar"; -function renderWithProviders() { - return render( - - - , - ); -} +const NAV_LINKS: Record = { + Home: "/dashboard", + Services: "/services", + Assets: "/assets", + Locations: "/locations", + Taxonomy: "/taxonomy", + Labels: "/labels", + Settings: "/settings", +}; describe("AppSidebar", () => { - it("renders the Stowage brand name", () => { - renderWithProviders(); - // SidebarProvider renders desktop + mobile, so brand may appear multiple times - const brands = screen.getAllByText("Stowage"); - expect(brands.length).toBeGreaterThanOrEqual(1); + beforeEach(() => { + pathnameSpy.mockReturnValue("/dashboard"); + overdueSpy.mockReturnValue(0); + }); + + it("renders the Stowage brand linking home", () => { + render(); + expect(screen.getByRole("link", { name: "Stowage" })).toHaveAttribute( + "href", + "/dashboard", + ); }); - it("renders all navigation links", () => { - renderWithProviders(); - const expectedItems = [ - "Dashboard", - "Assets", - "Locations", - "Taxonomy", - "Services", - "Labels", - "Settings", - ]; - for (const item of expectedItems) { - const elements = screen.getAllByText(item); - expect(elements.length).toBeGreaterThanOrEqual(1); + it("renders search, Scan a label and the user menu", () => { + render(); + const sidebar = screen.getByRole("complementary", { name: "Main" }); + expect( + within(sidebar).getByRole("button", { name: "Search assets" }), + ).toHaveAttribute("data-variant", "sidebar"); + expect( + within(sidebar).getByRole("link", { name: "Scan a label" }), + ).toHaveAttribute("href", "/scan"); + expect( + within(sidebar).getByRole("button", { name: "Open user menu" }), + ).toBeInTheDocument(); + }); + + it("renders every nav item with its href", () => { + render(); + for (const [name, href] of Object.entries(NAV_LINKS)) { + expect(screen.getByRole("link", { name })).toHaveAttribute("href", href); } + expect(screen.queryByRole("link", { name: "Dashboard" })).toBeNull(); + expect(screen.getByText("Inventory")).toBeInTheDocument(); }); - it("renders navigation links with correct hrefs", () => { - renderWithProviders(); - const links: Record = { - Dashboard: "/dashboard", - Assets: "/assets", - Locations: "/locations", - Taxonomy: "/taxonomy", - Services: "/services", - Labels: "/labels", - Settings: "/settings", - }; - for (const [name, href] of Object.entries(links)) { - const elements = screen.getAllByText(name); - const link = elements[0].closest("a"); - expect(link).toHaveAttribute("href", href); + it("marks the current item with aria-current", () => { + pathnameSpy.mockReturnValue("/categories"); + render(); + expect(screen.getByRole("link", { name: "Taxonomy" })).toHaveAttribute( + "aria-current", + "page", + ); + expect( + screen.getByRole("link", { name: "Home" }).getAttribute("aria-current"), + ).toBeNull(); + }); + + it("shows the overdue badge on Services only when there are overdue services", () => { + overdueSpy.mockReturnValue(3); + const { unmount } = render(); + const services = screen.getByRole("link", { name: /Services/ }); + expect(within(services).getByLabelText("3 overdue")).toHaveTextContent("3"); + unmount(); + + overdueSpy.mockReturnValue(0); + render(); + expect(screen.queryByLabelText(/overdue/)).toBeNull(); + }); +}); + +describe("AppRail", () => { + beforeEach(() => { + pathnameSpy.mockReturnValue("/assets/abc"); + overdueSpy.mockReturnValue(0); + }); + + it("renders labelled icon links for every nav item plus home, search and scan", () => { + render(); + expect(screen.getByRole("link", { name: "Stowage home" })).toHaveAttribute( + "href", + "/dashboard", + ); + for (const [name, href] of Object.entries(NAV_LINKS)) { + expect(screen.getByRole("link", { name })).toHaveAttribute("href", href); } + expect(screen.getByRole("link", { name: "Scan" })).toHaveAttribute( + "href", + "/scan", + ); + expect( + screen.getByRole("button", { name: "Search assets" }), + ).toHaveAttribute("data-variant", "rail"); + }); + + it("announces the overdue count in the Services link name", () => { + overdueSpy.mockReturnValue(4); + render(); + expect( + screen.getByRole("link", { name: "Services, 4 overdue" }), + ).toHaveAttribute("href", "/services"); + }); + + it("marks the current item with aria-current", () => { + render(); + expect(screen.getByRole("link", { name: "Assets" })).toHaveAttribute( + "aria-current", + "page", + ); }); }); diff --git a/src/__tests__/components/layout/bottom-nav.test.tsx b/src/__tests__/components/layout/bottom-nav.test.tsx index e0bcea3..4897bdb 100644 --- a/src/__tests__/components/layout/bottom-nav.test.tsx +++ b/src/__tests__/components/layout/bottom-nav.test.tsx @@ -5,7 +5,11 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; const pathnameSpy = vi.fn(() => "/dashboard"); const replaceSpy = vi.fn(); const logoutSpy = vi.fn(); -const toastErrorSpy = vi.fn(); +const overdueSpy = vi.fn(() => 0); + +vi.mock("@/hooks/use-overdue-count", () => ({ + useOverdueCount: () => overdueSpy(), +})); vi.mock("next/navigation", () => ({ usePathname: () => pathnameSpy(), @@ -21,10 +25,6 @@ vi.mock("@/lib/api/auth", () => ({ logout: () => logoutSpy(), })); -vi.mock("sonner", () => ({ - toast: { error: (...args: unknown[]) => toastErrorSpy(...args) }, -})); - import { BottomNav } from "@/components/layout/bottom-nav"; function renderNav() { @@ -43,7 +43,7 @@ describe("BottomNav", () => { pathnameSpy.mockReturnValue("/dashboard"); replaceSpy.mockReset(); logoutSpy.mockReset(); - toastErrorSpy.mockReset(); + overdueSpy.mockReturnValue(0); }); it("renders all five slots with the scan button in the center", () => { @@ -56,13 +56,43 @@ describe("BottomNav", () => { const scan = screen.getByTestId("bottom-nav-scan"); expect(scan).toHaveAttribute("href", "/scan"); - expect(scan.className).toMatch(/var\(--scan\)/); + expect(scan).toHaveAccessibleName("Scan a label"); + // The amber slab is the inner span; it is the only use of --scan. + expect(scan.querySelector("span")?.className).toMatch(/var\(--scan\)/); + + const order = Array.from( + screen.getByTestId("bottom-nav").querySelectorAll("[data-testid]"), + ).map((el) => el.getAttribute("data-testid")); + expect(order).toEqual([ + "bottom-nav-home", + "bottom-nav-assets", + "bottom-nav-scan", + "bottom-nav-services", + "bottom-nav-more", + ]); }); - it("hides itself on large viewports", () => { + it("links the tabs to their pages", () => { + renderNav(); + expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute( + "href", + "/dashboard", + ); + expect(screen.getByRole("link", { name: "Assets" })).toHaveAttribute( + "href", + "/assets", + ); + expect(screen.getByRole("link", { name: "Services" })).toHaveAttribute( + "href", + "/services", + ); + expect(screen.getByRole("button", { name: "More" })).toBeInTheDocument(); + }); + + it("hides itself from tablet width up", () => { renderNav(); const nav = screen.getByTestId("bottom-nav"); - expect(nav.className).toMatch(/lg:hidden/); + expect(nav.className).toMatch(/(^|\s)md:hidden(\s|$)/); }); it("marks Home active on /dashboard", () => { @@ -98,6 +128,26 @@ describe("BottomNav", () => { ); }); + it("shows the overdue count on Services", () => { + overdueSpy.mockReturnValue(2); + renderNav(); + expect(screen.getByTestId("bottom-nav-services")).toHaveTextContent( + "Services2", + ); + expect( + screen.getByRole("link", { name: "Services, 2 overdue" }), + ).toBe(screen.getByTestId("bottom-nav-services")); + }); + + it("marks More active on pages that live in the More sheet", () => { + pathnameSpy.mockReturnValue("/locations"); + renderNav(); + expect(screen.getByTestId("bottom-nav-more")).toHaveAttribute( + "aria-current", + "page", + ); + }); + it("marks SCAN active on /scan", () => { pathnameSpy.mockReturnValue("/scan"); renderNav(); @@ -145,19 +195,4 @@ describe("BottomNav", () => { expect(replaceSpy).toHaveBeenCalledWith("/login"); }); }); - - it("keeps the user signed in when logout rejects", async () => { - logoutSpy.mockRejectedValue(new Error("network")); - renderNav(); - fireEvent.click(screen.getByTestId("bottom-nav-more")); - fireEvent.click(screen.getByTestId("more-sheet-signout")); - - await waitFor(() => { - expect(logoutSpy).toHaveBeenCalledOnce(); - expect(toastErrorSpy).toHaveBeenCalledWith( - "Could not sign out. Please try again.", - ); - }); - expect(replaceSpy).not.toHaveBeenCalled(); - }); }); diff --git a/src/__tests__/components/layout/mobile-action-sheet.test.tsx b/src/__tests__/components/layout/mobile-action-sheet.test.tsx index 22ea3cc..adc7f38 100644 --- a/src/__tests__/components/layout/mobile-action-sheet.test.tsx +++ b/src/__tests__/components/layout/mobile-action-sheet.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { MobileActionSheet } from "@/components/layout/mobile-action-sheet"; @@ -72,4 +72,38 @@ describe("MobileActionSheet", () => { fireEvent.keyDown(document.body, { key: "Escape" }); expect(onOpenChange).toHaveBeenCalledWith(false); }); + + it("focuses the sheet itself by default so the phone keyboard stays down", async () => { + render( + {}} title="Actions"> + + , + ); + await waitFor(() => { + expect(screen.getByTestId("mobile-action-sheet")).toHaveFocus(); + }); + }); + + it("keeps focus on a field that asks for it with autoFocus", async () => { + render( + {}} title="Type the tag"> + + , + ); + await waitFor(() => { + expect(screen.getByLabelText("Asset tag")).toHaveFocus(); + }); + }); + + it("focuses a field marked data-autofocus", async () => { + render( + {}} title="Rename"> + + + , + ); + await waitFor(() => { + expect(screen.getByLabelText("Name")).toHaveFocus(); + }); + }); }); diff --git a/src/__tests__/components/layout/mobile-top-bar.test.tsx b/src/__tests__/components/layout/mobile-top-bar.test.tsx new file mode 100644 index 0000000..e8ce1e8 --- /dev/null +++ b/src/__tests__/components/layout/mobile-top-bar.test.tsx @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import { act, render, screen } from "@testing-library/react"; + +const nav = vi.hoisted(() => ({ pathname: "/assets/abc", role: "admin" })); + +vi.mock("next/navigation", () => ({ + usePathname: () => nav.pathname, +})); + +vi.mock("@/hooks/use-current-user", () => ({ + useCurrentUser: () => ({ data: { role: nav.role } }), +})); + +vi.mock("@/components/search/global-search", () => ({ + GlobalSearch: () => ( + + ), +})); + +vi.mock("@/components/layout/user-menu", () => ({ + UserMenu: () => , +})); + +import { MobileBar, MobileTopBar } from "@/components/layout/mobile-top-bar"; + +describe("MobileTopBar", () => { + it("makes its own controls inert while a page bar covers it", async () => { + const { rerender } = render(); + const controls = screen.getByTestId("mobile-top-bar-controls"); + expect(controls).not.toHaveAttribute("inert"); + + await act(async () => { + rerender( + <> + + + , + ); + }); + + expect(screen.getByRole("link", { name: "Back to assets" })).toBeInTheDocument(); + expect(screen.getByTestId("mobile-top-bar-controls")).toHaveAttribute("inert"); + + await act(async () => { + rerender(); + }); + expect(screen.getByTestId("mobile-top-bar-controls")).not.toHaveAttribute( + "inert", + ); + }); + + it("offers New location on Locations only to admins", () => { + nav.pathname = "/locations"; + nav.role = "admin"; + const { unmount } = render(); + expect(screen.getByRole("link", { name: "New location" })).toHaveAttribute( + "href", + "/locations?new=1", + ); + unmount(); + + nav.role = "user"; + render(); + expect(screen.queryByRole("link", { name: "New location" })).toBeNull(); + expect(screen.getByRole("link", { name: "New asset" })).toHaveAttribute( + "href", + "/assets/new", + ); + nav.pathname = "/assets/abc"; + nav.role = "admin"; + }); +}); diff --git a/src/__tests__/components/layout/topbar.test.tsx b/src/__tests__/components/layout/topbar.test.tsx deleted file mode 100644 index 06f6e46..0000000 --- a/src/__tests__/components/layout/topbar.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen } from "@testing-library/react"; - -vi.mock("next/navigation", () => ({ - usePathname: () => "/dashboard", - useRouter: () => ({ replace: vi.fn() }), -})); - -vi.mock("next-themes", () => ({ - useTheme: () => ({ theme: "light", setTheme: vi.fn() }), - ThemeProvider: ({ children }: { children: React.ReactNode }) => children, -})); - -vi.mock("@/hooks/use-mobile", () => ({ - useIsMobile: () => false, -})); - -vi.mock("@/lib/api/auth", () => ({ - getCurrentUser: () => - Promise.resolve({ - id: "u1", - email: "alex@example.com", - name: "Alex Admin", - role: "admin", - }), - logout: vi.fn(), -})); - -vi.mock("@/components/search/global-search", () => ({ - GlobalSearch: () => ( - - ), -})); - -import { SidebarProvider } from "@/components/ui/sidebar"; -import { Topbar } from "@/components/layout/topbar"; - -function renderWithProviders() { - const qc = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return render( - - - - - , - ); -} - -describe("Topbar", () => { - it("renders the global search trigger", () => { - renderWithProviders(); - const triggers = screen.getAllByRole("button", { - name: "Open global search", - }); - expect(triggers.length).toBeGreaterThanOrEqual(1); - }); - - it("renders the theme toggle button", () => { - renderWithProviders(); - const toggles = screen.getAllByText("Toggle theme"); - expect(toggles.length).toBeGreaterThanOrEqual(1); - }); - - it("renders the user menu button", () => { - renderWithProviders(); - const menus = screen.getAllByLabelText("Open user menu"); - expect(menus.length).toBeGreaterThanOrEqual(1); - }); - - it("search trigger is interactive", () => { - renderWithProviders(); - expect( - screen.getAllByRole("button", { name: "Open global search" })[0], - ).not.toBeDisabled(); - }); -}); diff --git a/src/__tests__/components/layout/user-menu.test.tsx b/src/__tests__/components/layout/user-menu.test.tsx new file mode 100644 index 0000000..649f118 --- /dev/null +++ b/src/__tests__/components/layout/user-menu.test.tsx @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const replaceSpy = vi.fn(); +const logoutSpy = vi.fn(); +const setThemeSpy = vi.fn(); +const toastErrorSpy = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: replaceSpy }), +})); + +vi.mock("next-themes", () => ({ + useTheme: () => ({ theme: "light", setTheme: setThemeSpy }), +})); + +vi.mock("@/lib/api/auth", () => ({ + getCurrentUser: () => + Promise.resolve({ + id: "u1", + email: "alex@example.com", + name: "Alex Admin", + role: "admin", + }), + logout: () => logoutSpy(), +})); + +vi.mock("sonner", () => ({ + toast: { error: (...args: unknown[]) => toastErrorSpy(...args) }, +})); + +import { UserMenu } from "@/components/layout/user-menu"; + +function renderMenu() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +} + +async function openMenu() { + const user = userEvent.setup(); + const trigger = screen.getByRole("button", { name: "Open user menu" }); + await waitFor(() => expect(trigger).toBeEnabled()); + await user.click(trigger); + return user; +} + +describe("UserMenu", () => { + beforeEach(() => { + replaceSpy.mockReset(); + logoutSpy.mockReset(); + setThemeSpy.mockReset(); + toastErrorSpy.mockReset(); + }); + + it("shows the signed-in user's name and role on the trigger", async () => { + renderMenu(); + expect(await screen.findByText("Alex Admin")).toBeInTheDocument(); + expect(screen.getByText("Admin")).toBeInTheDocument(); + }); + + it("opens with the email, appearance options and sign out", async () => { + renderMenu(); + const user = await openMenu(); + + expect(await screen.findByText("alex@example.com")).toBeInTheDocument(); + await user.click(screen.getByRole("menuitem", { name: "Dark" })); + expect(setThemeSpy).toHaveBeenCalledWith("dark"); + }); + + it("signs out and redirects to login", async () => { + logoutSpy.mockResolvedValue(undefined); + renderMenu(); + const user = await openMenu(); + + await user.click(await screen.findByRole("menuitem", { name: "Sign out" })); + + await waitFor(() => { + expect(logoutSpy).toHaveBeenCalledOnce(); + expect(replaceSpy).toHaveBeenCalledWith("/login"); + }); + }); + + it("keeps the user signed in when logout rejects", async () => { + logoutSpy.mockRejectedValue(new Error("network")); + renderMenu(); + const user = await openMenu(); + + await user.click(await screen.findByRole("menuitem", { name: "Sign out" })); + + await waitFor(() => { + expect(logoutSpy).toHaveBeenCalledOnce(); + expect(toastErrorSpy).toHaveBeenCalledWith( + "Could not sign out. Please try again.", + ); + }); + expect(replaceSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/components/locations/location-tree.test.tsx b/src/__tests__/components/locations/location-tree.test.tsx deleted file mode 100644 index a495282..0000000 --- a/src/__tests__/components/locations/location-tree.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { - LocationTree, - buildLocationChildrenMap, - collectDescendantIds, - type LocationTreeItem, -} from "@/components/locations/location-tree"; - -const locations: LocationTreeItem[] = [ - { - id: "root", - name: "Warehouse", - parentId: null, - description: null, - path: "Warehouse", - createdAt: 1, - updatedAt: 1, - }, - { - id: "child", - name: "Aisle 1", - parentId: "root", - description: null, - path: "Warehouse / Aisle 1", - createdAt: 2, - updatedAt: 2, - }, - { - id: "grandchild", - name: "Bin 2", - parentId: "child", - description: null, - path: "Warehouse / Aisle 1 / Bin 2", - createdAt: 3, - updatedAt: 3, - }, -]; - -describe("location tree helpers", () => { - it("collects descendant ids", () => { - const childrenByParent = buildLocationChildrenMap(locations); - expect( - Array.from(collectDescendantIds("root", childrenByParent)).sort(), - ).toEqual(["child", "grandchild"]); - expect(Array.from(collectDescendantIds("child", childrenByParent))).toEqual( - ["grandchild"], - ); - }); -}); - -describe("LocationTree", () => { - it("renders root nodes and expands nested children", async () => { - const user = userEvent.setup(); - const onToggleExpand = vi.fn(); - const onSelect = vi.fn(); - - const { rerender } = render( - ()} - canManage={false} - onToggleExpand={onToggleExpand} - onSelect={onSelect} - onAddChild={vi.fn()} - onDelete={vi.fn()} - />, - ); - - expect( - screen.getByRole("button", { name: /Warehouse/i }), - ).toBeInTheDocument(); - expect(screen.queryByText("Aisle 1")).not.toBeInTheDocument(); - - await user.click(screen.getByRole("button", { name: "Expand" })); - expect(onToggleExpand).toHaveBeenCalledWith("root"); - - rerender( - , - ); - - expect(screen.getByText("Aisle 1")).toBeInTheDocument(); - expect(screen.getByText("Warehouse / Aisle 1 / Bin 2")).toBeInTheDocument(); - - const rootLabel = screen.getAllByText("Warehouse")[0]; - const rootSelectButton = rootLabel.closest("button"); - expect(rootSelectButton).not.toBeNull(); - await user.click(rootSelectButton!); - expect(onSelect).toHaveBeenCalledWith("root"); - }); -}); diff --git a/src/__tests__/components/locations/locations-page-client.test.tsx b/src/__tests__/components/locations/locations-page-client.test.tsx new file mode 100644 index 0000000..3742ab5 --- /dev/null +++ b/src/__tests__/components/locations/locations-page-client.test.tsx @@ -0,0 +1,267 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const listLocationsMock = vi.fn(); +const listAssetsMock = vi.fn(); +const deleteLocationMock = vi.fn(); +const currentUserMock = vi.fn(); +const mockReplace = vi.fn(); +const mockPush = vi.fn(); +const toastInfo = vi.fn(); +let currentSearchParams = new URLSearchParams(); + +vi.mock("@/lib/api/locations", () => ({ + listLocations: () => listLocationsMock(), + createLocation: vi.fn(), + updateLocation: vi.fn(), + deleteLocation: (id: string) => deleteLocationMock(id), +})); + +vi.mock("@/lib/api/assets", () => ({ + listAssets: () => listAssetsMock(), +})); + +vi.mock("@/lib/api/app-settings", () => ({ + getAppSettings: () => Promise.resolve({ serviceSchedulingEnabled: false }), +})); + +vi.mock("@/lib/api/service-schedules", () => ({ + listScheduledAssets: () => Promise.resolve([]), +})); + +vi.mock("@/hooks/use-current-user", () => ({ + useCurrentUser: () => ({ data: currentUserMock() }), +})); + +vi.mock("@/hooks/use-media-query", () => ({ + useMediaQuery: () => false, +})); + +vi.mock("@/components/layout/mobile-top-bar", () => ({ + MobileTitleCount: () => null, +})); + +vi.mock("sonner", () => ({ + toast: { + info: (message: string) => toastInfo(message), + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: mockReplace, push: mockPush }), + useSearchParams: () => currentSearchParams, +})); + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + ...props + }: React.AnchorHTMLAttributes & { href: string }) => ( + + {children} + + ), +})); + +import { LocationsPageClient } from "@/components/locations/locations-page-client"; + +function location( + id: string, + name: string, + parentId: string | null, + path: string, +) { + return { + id, + name, + parentId, + description: null, + path, + createdAt: 0, + updatedAt: 0, + }; +} + +const LOCATIONS = [ + location("w", "Workshop", null, "Workshop"), + location("b", "Bench 2", "w", "Workshop / Bench 2"), + location("d", "Drawer", "b", "Workshop / Bench 2 / Drawer"), + location("s", "Store", null, "Store"), + location("e", "Shelf", "s", "Store / Shelf"), +]; + +function asset( + id: string, + locationId: string | null, + locationPath: string | null, +) { + return { + id, + name: `Asset ${id}`, + assetTag: `AS-${id}`, + status: "active", + categoryId: null, + categoryName: null, + categoryColor: null, + locationId, + locationPath, + serviceGroupId: null, + notes: null, + tagIds: [], + tagNames: [], + createdAt: 0, + updatedAt: 0, + }; +} + +const ASSETS = [ + asset("1", "b", "Workshop / Bench 2"), + asset("2", "d", "Workshop / Bench 2 / Drawer"), + asset("3", "w", "Workshop"), + asset("4", null, null), +]; + +function renderPage() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const ui = () => ( + + + + ); + const result = render(ui()); + return { ...result, rerenderPage: () => result.rerender(ui()) }; +} + +function treeRow(name: string) { + const tree = screen.getByRole("list", { name: "Location tree" }); + return within(tree).queryByRole("button", { + name: new RegExp(`^${name}\\d+$`), + }); +} + +describe("LocationsPageClient", () => { + beforeEach(() => { + vi.clearAllMocks(); + currentSearchParams = new URLSearchParams(); + listLocationsMock.mockResolvedValue(LOCATIONS); + listAssetsMock.mockResolvedValue(ASSETS); + currentUserMock.mockReturnValue({ id: "u1", role: "admin" }); + }); + + it("rolls asset counts up the tree and leaves unplaced assets out of the subline", async () => { + currentSearchParams = new URLSearchParams("location=d"); + renderPage(); + + await screen.findByRole("list", { name: "Location tree" }); + expect(treeRow("Workshop")).toHaveTextContent("Workshop3"); + expect(treeRow("Bench 2")).toHaveTextContent("Bench 22"); + expect(treeRow("Drawer")).toHaveTextContent("Drawer1"); + expect(treeRow("Store")).toHaveTextContent("Store0"); + expect(screen.getByText("3 assets across 2 sites")).toBeInTheDocument(); + }); + + it("selects the location named in ?location= and opens the path to it", async () => { + currentSearchParams = new URLSearchParams("location=d"); + renderPage(); + + const drawer = await waitFor(() => { + const row = treeRow("Drawer"); + expect(row).not.toBeNull(); + return row!; + }); + expect(drawer).toHaveAttribute("aria-current", "true"); + expect( + within(screen.getByTestId("location-detail")).getByRole("heading", { + level: 2, + }), + ).toHaveTextContent("Drawer"); + }); + + it("reveals a newly selected location even after rows were collapsed", async () => { + const user = userEvent.setup(); + currentSearchParams = new URLSearchParams("location=w"); + const { rerenderPage } = renderPage(); + + await waitFor(() => expect(treeRow("Bench 2")).not.toBeNull()); + await user.click(screen.getByRole("button", { name: "Collapse Workshop" })); + expect(treeRow("Bench 2")).toBeNull(); + + currentSearchParams = new URLSearchParams("location=d"); + rerenderPage(); + + expect(treeRow("Bench 2")).not.toBeNull(); + expect(treeRow("Drawer")).toHaveAttribute("aria-current", "true"); + + // Once revealed, the user can still close the path again. + await user.click(screen.getByRole("button", { name: "Collapse Workshop" })); + expect(treeRow("Drawer")).toBeNull(); + }); + + it("opens New location for admins on ?new=1", async () => { + currentSearchParams = new URLSearchParams("new=1"); + renderPage(); + + expect( + await screen.findByRole("dialog", { name: "New location" }), + ).toBeInTheDocument(); + }); + + it("drops ?new=1 for non-admins and says why", async () => { + currentUserMock.mockReturnValue({ id: "u2", role: "user" }); + currentSearchParams = new URLSearchParams("new=1&location=s"); + renderPage(); + + await waitFor(() => + expect(toastInfo).toHaveBeenCalledWith("Only admins can add locations."), + ); + expect(mockReplace).toHaveBeenCalledWith("/locations?location=s", { + scroll: false, + }); + expect( + screen.queryByRole("dialog", { name: "New location" }), + ).not.toBeInTheDocument(); + }); + + it("blocks deleting a location that still holds assets or sub-locations", async () => { + const user = userEvent.setup(); + currentSearchParams = new URLSearchParams("location=w"); + renderPage(); + + await screen.findByTestId("location-detail"); + await user.click(screen.getByRole("button", { name: "Location actions" })); + const item = await screen.findByRole("menuitem", { name: /Delete…/ }); + expect(item).toHaveAttribute("aria-disabled", "true"); + expect(item).toHaveTextContent( + "Move out its 1 asset and 1 sub-location first", + ); + }); + + it("deletes an empty location", async () => { + const user = userEvent.setup(); + deleteLocationMock.mockResolvedValue(undefined); + currentSearchParams = new URLSearchParams("location=e"); + renderPage(); + + await screen.findByTestId("location-detail"); + await user.click(screen.getByRole("button", { name: "Location actions" })); + const item = await screen.findByRole("menuitem", { name: "Delete…" }); + expect(item).not.toHaveAttribute("aria-disabled"); + await user.click(item); + + const dialog = await screen.findByRole("dialog", { + name: "Delete location?", + }); + expect(dialog).toHaveTextContent( + "Store / Shelf is deleted. It holds no assets or sub-locations", + ); + await user.click( + within(dialog).getByRole("button", { name: "Delete location" }), + ); + await waitFor(() => expect(deleteLocationMock).toHaveBeenCalledWith("e")); + }); +}); diff --git a/src/__tests__/components/scan/scan-page-client.test.tsx b/src/__tests__/components/scan/scan-page-client.test.tsx index 5538e9c..36e4b18 100644 --- a/src/__tests__/components/scan/scan-page-client.test.tsx +++ b/src/__tests__/components/scan/scan-page-client.test.tsx @@ -185,6 +185,15 @@ describe("ScanPageClient", () => { ); }); + it("puts the cursor in the tag field when the manual sheet opens", async () => { + renderScanPage(); + fireEvent.click(screen.getByTestId("scan-manual-entry")); + const input = await screen.findByTestId("scan-manual-input"); + await waitFor(() => { + expect(input).toHaveFocus(); + }); + }); + it("shows the unresolved sheet with raw text on a missing asset", async () => { resolverMock.mockResolvedValue({ status: "unresolved", diff --git a/src/__tests__/components/search/global-search.test.tsx b/src/__tests__/components/search/global-search.test.tsx index 96e7aca..47317ae 100644 --- a/src/__tests__/components/search/global-search.test.tsx +++ b/src/__tests__/components/search/global-search.test.tsx @@ -14,7 +14,10 @@ vi.mock("@/lib/api/search", () => ({ searchAssets: (term: string) => searchAssetsMock(term), })); -import { GlobalSearch } from "@/components/search/global-search"; +import { + GlobalSearch, + GlobalSearchDialog, +} from "@/components/search/global-search"; function createResult(overrides?: Partial>) { return { @@ -48,7 +51,12 @@ describe("GlobalSearch", () => { const user = userEvent.setup(); - renderWithClient(); + renderWithClient( + <> + + + , + ); await user.click( screen.getByRole("button", { name: "Open global search" }), @@ -77,7 +85,12 @@ describe("GlobalSearch", () => { const user = userEvent.setup(); - renderWithClient(); + renderWithClient( + <> + + + , + ); await act(async () => { window.dispatchEvent( @@ -102,4 +115,50 @@ describe("GlobalSearch", () => { { timeout: 2000 }, ); }); + + it("opens exactly one dialog from the shortcut with every trigger mounted", async () => { + searchAssetsMock.mockResolvedValue([]); + + renderWithClient( + <> + + + + + , + ); + + expect( + screen.getAllByRole("button", { name: "Open global search" }), + ).toHaveLength(3); + + await act(async () => { + window.dispatchEvent( + new KeyboardEvent("keydown", { key: "k", metaKey: true }), + ); + }); + + expect(screen.getAllByRole("dialog")).toHaveLength(1); + expect( + screen.getAllByPlaceholderText("Search assets by name, tag, or notes..."), + ).toHaveLength(1); + }); + + it("opens the one dialog from any trigger", async () => { + searchAssetsMock.mockResolvedValue([]); + const user = userEvent.setup(); + + renderWithClient( + <> + + + + , + ); + + await user.click( + screen.getAllByRole("button", { name: "Open global search" })[1], + ); + expect(screen.getAllByRole("dialog")).toHaveLength(1); + }); }); diff --git a/src/__tests__/components/services/next-service-panel.test.tsx b/src/__tests__/components/services/next-service-panel.test.tsx new file mode 100644 index 0000000..0ad3dd8 --- /dev/null +++ b/src/__tests__/components/services/next-service-panel.test.tsx @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; + +const getScheduleByAssetIdMock = vi.fn(); +const getAppSettingsMock = vi.fn(); + +vi.mock("@/lib/api/service-schedules", () => ({ + getScheduleByAssetId: (assetId: string) => getScheduleByAssetIdMock(assetId), +})); + +vi.mock("@/lib/api/app-settings", () => ({ + getAppSettings: () => getAppSettingsMock(), +})); + +vi.mock("@/lib/use-today-iso-date", () => ({ + useTodayIsoDate: () => "2026-03-07", +})); + +vi.mock("@/lib/use-app-date-format", () => ({ + useAppDateFormat: () => "DD-MM-YYYY", +})); + +vi.mock("@/components/services/service-record-form", () => ({ + ServiceRecordForm: ({ mode }: { mode: string }) => ( +
Service record form ({mode})
+ ), +})); + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + ...props + }: React.AnchorHTMLAttributes & { href: string }) => ( + + {children} + + ), +})); + +import { NextServicePanel } from "@/components/services/next-service-panel"; + +function makeSchedule(overrides: Record = {}) { + return { + id: "schedule1", + assetId: "asset1", + nextServiceDate: "2026-03-17", + intervalValue: 3, + intervalUnit: "months", + reminderLeadValue: 14, + reminderLeadUnit: "days", + reminderStartDate: "2026-03-03", + createdAt: 1, + updatedAt: 1, + createdBy: "user1", + updatedBy: "user1", + ...overrides, + }; +} + +function renderPanel(serviceGroupName: string | null = "Engine checks") { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +} + +describe("NextServicePanel", () => { + beforeEach(() => { + getScheduleByAssetIdMock.mockReset(); + getAppSettingsMock.mockReset(); + getAppSettingsMock.mockResolvedValue({ + serviceSchedulingEnabled: true, + dateFormat: "DD-MM-YYYY", + }); + }); + + it("shows the due distance, plan and date, interval and reminder when scheduled", async () => { + getScheduleByAssetIdMock.mockResolvedValue(makeSchedule()); + renderPanel(); + + expect(await screen.findByText("In 10 days")).toBeInTheDocument(); + expect(screen.getByText("Engine checks · 17-03-2026")).toBeInTheDocument(); + expect(screen.getByText("Interval")).toBeInTheDocument(); + expect(screen.getByText("Every 3 months")).toBeInTheDocument(); + expect(screen.getByText("Reminder")).toBeInTheDocument(); + expect(screen.getByText("14 days before")).toBeInTheDocument(); + expect(getScheduleByAssetIdMock).toHaveBeenCalledWith("asset1"); + }); + + it("states how far overdue a past-due service is", async () => { + getScheduleByAssetIdMock.mockResolvedValue( + makeSchedule({ nextServiceDate: "2026-03-04" }), + ); + renderPanel(null); + + expect(await screen.findByText("Overdue 3 days")).toBeInTheDocument(); + expect(screen.getByText("04-03-2026")).toBeInTheDocument(); + }); + + it("uses singular units for an interval or reminder of one", async () => { + getScheduleByAssetIdMock.mockResolvedValue( + makeSchedule({ + intervalValue: 1, + intervalUnit: "years", + reminderLeadValue: 1, + reminderLeadUnit: "weeks", + }), + ); + renderPanel(); + + expect(await screen.findByText("Every year")).toBeInTheDocument(); + expect(screen.getByText("1 week before")).toBeInTheDocument(); + }); + + it("keeps Log service… disabled until the schedule has loaded", async () => { + let resolveSchedule: (value: unknown) => void = () => {}; + getScheduleByAssetIdMock.mockReturnValue( + new Promise((resolve) => { + resolveSchedule = resolve; + }), + ); + renderPanel(); + + expect(screen.getByRole("button", { name: "Log service…" })).toBeDisabled(); + + resolveSchedule(makeSchedule()); + await screen.findByText("In 10 days"); + expect(screen.getByRole("button", { name: "Log service…" })).toBeEnabled(); + }); + + it("offers Log service… and Reschedule… when scheduled", async () => { + const user = userEvent.setup(); + getScheduleByAssetIdMock.mockResolvedValue(makeSchedule()); + renderPanel(); + + await screen.findByText("In 10 days"); + expect(screen.getByRole("link", { name: "Reschedule…" })).toHaveAttribute( + "href", + "/assets/asset1/edit#service", + ); + + await user.click(screen.getByRole("button", { name: "Log service…" })); + expect( + await screen.findByText("Service record form (complete)"), + ).toBeInTheDocument(); + expect(screen.getByText("Log service: Forklift A")).toBeInTheDocument(); + }); + + it("shows Not scheduled with Schedule… and a plain log entry when there is no schedule", async () => { + const user = userEvent.setup(); + getScheduleByAssetIdMock.mockResolvedValue(null); + renderPanel(); + + expect(await screen.findByText("Not scheduled")).toBeInTheDocument(); + expect( + screen.getByText("Set an interval to get reminders before it is due."), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Schedule…" })).toHaveAttribute( + "href", + "/assets/asset1/edit#service", + ); + expect(screen.queryByRole("link", { name: "Reschedule…" })).toBeNull(); + + await user.click(screen.getByRole("button", { name: "Log service…" })); + expect( + await screen.findByText("Service record form (create)"), + ).toBeInTheDocument(); + }); + + it("hides scheduling when it is turned off in Settings", async () => { + getAppSettingsMock.mockResolvedValue({ + serviceSchedulingEnabled: false, + dateFormat: "DD-MM-YYYY", + }); + getScheduleByAssetIdMock.mockResolvedValue(makeSchedule()); + renderPanel(); + + expect( + await screen.findByText("Service scheduling is turned off in Settings."), + ).toBeInTheDocument(); + expect(screen.getByText("Not scheduled")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /Schedule…/ })).toBeNull(); + expect( + screen.getByRole("button", { name: "Log service…" }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/components/services/service-group-assets-panel.test.tsx b/src/__tests__/components/services/service-group-assets-panel.test.tsx index 3c09429..74e6cc9 100644 --- a/src/__tests__/components/services/service-group-assets-panel.test.tsx +++ b/src/__tests__/components/services/service-group-assets-panel.test.tsx @@ -43,9 +43,8 @@ describe("ServiceGroupAssetsPanel", () => { await waitFor(() => { expect( - screen.getAllByText("No assets are currently assigned to this group.") - .length, - ).toBeGreaterThan(0); + screen.getByText(/No assets are on this plan yet/), + ).toBeInTheDocument(); }); }); @@ -68,12 +67,11 @@ describe("ServiceGroupAssetsPanel", () => { ), ).toBeInTheDocument(); }); - const mobile = within(screen.getByTestId("service-group-assets-card-list")); - const desktop = within(screen.getByTestId("service-group-assets-table")); - expect(mobile.getByText("AST-0001")).toBeInTheDocument(); - expect(mobile.getByText("active")).toBeInTheDocument(); - expect(desktop.getByText("Generator")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Open asset" })).toHaveAttribute( + const list = within(screen.getByTestId("service-group-assets-card-list")); + expect(list.getByText("AST-0001")).toBeInTheDocument(); + // Active is the norm, so it carries no status text. + expect(list.queryByText("Active")).toBeNull(); + expect(screen.getByRole("link", { name: /Generator/ })).toHaveAttribute( "href", "/assets/asset1", ); diff --git a/src/__tests__/components/services/service-group-detail-page-client.test.tsx b/src/__tests__/components/services/service-group-detail-page-client.test.tsx index 03feca5..8f87e0c 100644 --- a/src/__tests__/components/services/service-group-detail-page-client.test.tsx +++ b/src/__tests__/components/services/service-group-detail-page-client.test.tsx @@ -66,10 +66,10 @@ describe("ServiceGroupDetailPageClient", () => { renderWithClient(); await waitFor(() => { - expect(screen.getByText("Group not found")).toBeInTheDocument(); + expect(screen.getByText("Plan not found")).toBeInTheDocument(); }); expect( - screen.getByRole("link", { name: "Back to groups" }), + screen.getByRole("link", { name: "Back to service plans" }), ).toHaveAttribute("href", "/services/groups"); }); @@ -87,7 +87,7 @@ describe("ServiceGroupDetailPageClient", () => { }); expect(screen.getByText("Quarterly maintenance")).toBeInTheDocument(); expect( - screen.getByRole("button", { name: "Edit group" }), + screen.getByRole("button", { name: "Edit…" }), ).toBeInTheDocument(); expect(screen.getByText("FieldsPanel")).toBeInTheDocument(); expect(screen.getByText("AssetsPanel")).toBeInTheDocument(); diff --git a/src/__tests__/components/services/service-group-editor.test.tsx b/src/__tests__/components/services/service-group-editor.test.tsx index f9814ec..51703ee 100644 --- a/src/__tests__/components/services/service-group-editor.test.tsx +++ b/src/__tests__/components/services/service-group-editor.test.tsx @@ -17,7 +17,7 @@ describe("ServiceGroupEditor", () => { ); expect( - screen.queryByRole("heading", { name: "Create service group" }), + screen.queryByRole("heading", { name: "New service plan" }), ).not.toBeInTheDocument(); }); @@ -34,12 +34,12 @@ describe("ServiceGroupEditor", () => { ); expect( - screen.getByRole("heading", { name: "Create service group" }), + screen.getByRole("heading", { name: "New service plan" }), ).toBeVisible(); expect(screen.getByLabelText("Name")).toHaveValue(""); - expect(screen.getByLabelText("Description")).toHaveValue(""); + expect(screen.getByLabelText(/^Description/)).toHaveValue(""); expect( - screen.getByRole("button", { name: "Create group" }), + screen.getByRole("button", { name: "Create plan" }), ).toBeInTheDocument(); }); @@ -63,12 +63,12 @@ describe("ServiceGroupEditor", () => { ); expect( - screen.getByRole("heading", { name: "Edit service group" }), + screen.getByRole("heading", { name: "Edit service plan" }), ).toBeVisible(); expect(screen.getByLabelText("Name")).toHaveValue("Engine checks"); - expect(screen.getByLabelText("Description")).toHaveValue("Quarterly"); + expect(screen.getByLabelText(/^Description/)).toHaveValue("Quarterly"); - await user.click(screen.getByRole("button", { name: "Save changes" })); + await user.click(screen.getByRole("button", { name: "Save plan" })); expect(onSubmit).toHaveBeenCalledWith({ name: "Engine checks", description: "Quarterly", diff --git a/src/__tests__/components/services/service-group-fields-panel.test.tsx b/src/__tests__/components/services/service-group-fields-panel.test.tsx index b662701..89bb801 100644 --- a/src/__tests__/components/services/service-group-fields-panel.test.tsx +++ b/src/__tests__/components/services/service-group-fields-panel.test.tsx @@ -47,7 +47,7 @@ describe("ServiceGroupFieldsPanel", () => { await waitFor(() => { expect( screen.getAllByText( - "No fields yet. Add required service fields for this group.", + "No fields yet. A logged service then only asks for the date and a note.", ).length, ).toBeGreaterThan(0); }); diff --git a/src/__tests__/components/services/service-groups-list.test.tsx b/src/__tests__/components/services/service-groups-list.test.tsx index 908f53d..2bf3e34 100644 --- a/src/__tests__/components/services/service-groups-list.test.tsx +++ b/src/__tests__/components/services/service-groups-list.test.tsx @@ -66,9 +66,9 @@ describe("ServiceGroupsList", () => { "Engine checks", ), ).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Create group" })); + await user.click(screen.getByRole("button", { name: "New service plan…" })); expect( - screen.getByRole("heading", { name: "Create service group" }), + screen.getByRole("heading", { name: "New service plan" }), ).toBeVisible(); }); }); diff --git a/src/__tests__/components/services/service-history.test.tsx b/src/__tests__/components/services/service-history.test.tsx index 9f2a499..978cf61 100644 --- a/src/__tests__/components/services/service-history.test.tsx +++ b/src/__tests__/components/services/service-history.test.tsx @@ -3,14 +3,9 @@ import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor } from "@testing-library/react"; -const getScheduleByAssetIdMock = vi.fn(); const listAssetServiceRecordsMock = vi.fn(); const getCurrentUserMock = vi.fn(); -vi.mock("@/lib/api/service-schedules", () => ({ - getScheduleByAssetId: (assetId: string) => getScheduleByAssetIdMock(assetId), -})); - vi.mock("@/lib/api/service-records", () => ({ listAssetServiceRecords: (assetId: string) => listAssetServiceRecordsMock(assetId), @@ -35,6 +30,33 @@ vi.mock("@/lib/use-app-date-format", () => ({ import { ServiceHistory } from "@/components/services/service-history"; +const record = { + id: "record1", + assetId: "asset1", + serviceGroupId: "group1", + serviceGroupName: "Engine checks", + values: { field1: "Checked belts" }, + valueEntries: [ + { + fieldId: "field1", + label: "Technician note", + value: "Checked belts", + }, + ], + scheduleId: "schedule1", + scheduledForDate: "2026-03-10", + serviceDate: "2026-03-04", + description: "Completed service", + cost: 125, + providerId: "provider1", + providerName: "Dockside Repair", + completedAt: 1, + completedBy: "user1", + completedByName: "Admin User", + createdAt: 1, + updatedAt: 1, +}; + function renderWithClient(ui: React.ReactElement) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -44,7 +66,6 @@ function renderWithClient(ui: React.ReactElement) { describe("ServiceHistory", () => { beforeEach(() => { - getScheduleByAssetIdMock.mockReset(); listAssetServiceRecordsMock.mockReset(); getCurrentUserMock.mockReset(); getCurrentUserMock.mockResolvedValue({ @@ -55,65 +76,86 @@ describe("ServiceHistory", () => { }); }); - it("renders schedule summary and service records", async () => { + it("renders service records as a timeline with details behind a disclosure", async () => { const user = userEvent.setup(); - getScheduleByAssetIdMock.mockResolvedValue({ - id: "schedule1", - assetId: "asset1", - nextServiceDate: "2026-03-10", - intervalValue: 6, - intervalUnit: "months", - reminderLeadValue: 14, - reminderLeadUnit: "days", - reminderStartDate: "2026-02-24", - createdAt: 1, - updatedAt: 1, - createdBy: "user1", - updatedBy: "user1", - }); - listAssetServiceRecordsMock.mockResolvedValue([ - { - id: "record1", - assetId: "asset1", - serviceGroupId: "group1", - serviceGroupName: "Engine checks", - values: { field1: "Checked belts" }, - valueEntries: [ - { - fieldId: "field1", - label: "Technician note", - value: "Checked belts", - }, - ], - scheduleId: "schedule1", - scheduledForDate: "2026-03-10", - serviceDate: "2026-03-04", - description: "Completed service", - cost: 125, - providerId: "provider1", - providerName: "Dockside Repair", - completedAt: 1, - completedBy: "user1", - completedByName: "Admin User", - createdAt: 1, - updatedAt: 1, - }, - ]); + listAssetServiceRecordsMock.mockResolvedValue([record]); renderWithClient(); - await waitFor(() => { - expect(screen.getByText("Next due 10-03-2026")).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: /Service history/ }), + ).toBeInTheDocument(); + expect(await screen.findByText("Completed service")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /^Service history\s*1$/ })).toBeInTheDocument(); + + // Date (desktop and phone placements), what was done, and by whom. + expect(screen.getAllByText("04-03-2026")).toHaveLength(2); + expect(screen.getByText("Engine checks")).toBeInTheDocument(); + expect(screen.getByText("by Admin User")).toBeInTheDocument(); + expect( + screen.getByText(/Dockside Repair · Cost 125[.,]00 · Due 10-03-2026/), + ).toBeInTheDocument(); + + // Fields, files and actions stay closed until the disclosure is opened. + expect(screen.getByText("Technician note")).not.toBeVisible(); + expect(screen.getByText("Record attachments")).not.toBeVisible(); + + await user.click(screen.getByText("Fields and files")); + expect(screen.getByText("Technician note")).toBeVisible(); + expect(screen.getByText("Checked belts")).toBeVisible(); + expect(screen.getByText("Record attachments")).toBeVisible(); + expect(screen.getByRole("button", { name: "Edit…" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Delete…" })).toBeVisible(); + }); + + it("does not schedule anything itself (the Next service panel owns that)", async () => { + listAssetServiceRecordsMock.mockResolvedValue([record]); + + renderWithClient(); + + await screen.findByText("Completed service"); + expect(screen.queryByText(/Next due/)).not.toBeInTheDocument(); + }); + + it("hides record actions from users who neither completed the record nor are admin", async () => { + getCurrentUserMock.mockResolvedValue({ + id: "user2", + email: "b@x.com", + name: "Other User", + role: "user", }); - expect(screen.getByText("Completed service")).toBeInTheDocument(); + listAssetServiceRecordsMock.mockResolvedValue([record]); + + renderWithClient(); + + await screen.findByText("Completed service"); + expect(screen.queryByRole("button", { name: "Edit…" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Delete…" })).toBeNull(); + }); + + it("offers Log other service… unless the Next service panel owns logging", async () => { + const user = userEvent.setup(); + listAssetServiceRecordsMock.mockResolvedValue([]); - await user.click(screen.getByText("Completed service")); - expect(screen.getByText("Technician note")).toBeInTheDocument(); - expect(screen.getByText("Record attachments")).toBeInTheDocument(); + const { unmount } = renderWithClient(); + await user.click( + screen.getByRole("button", { name: "Log other service…" }), + ); + expect(await screen.findByText("Service record form")).toBeInTheDocument(); + // Logging any service advances an existing schedule; the copy must say so. + expect( + screen.getByText(/next service moves to one interval after this service date/), + ).toBeInTheDocument(); + expect(screen.queryByText(/without touching its schedule/)).toBeNull(); + unmount(); + + renderWithClient(); + expect( + screen.queryByRole("button", { name: "Log other service…" }), + ).toBeNull(); }); it("renders empty state when no records exist", async () => { - getScheduleByAssetIdMock.mockResolvedValue(null); listAssetServiceRecordsMock.mockResolvedValue([]); renderWithClient(); diff --git a/src/__tests__/components/services/service-providers-page-client.test.tsx b/src/__tests__/components/services/service-providers-page-client.test.tsx index b5ded12..135e8a1 100644 --- a/src/__tests__/components/services/service-providers-page-client.test.tsx +++ b/src/__tests__/components/services/service-providers-page-client.test.tsx @@ -61,8 +61,8 @@ describe("ServiceProvidersPageClient", () => { expect(screen.getByText("No service providers yet.")).toBeInTheDocument(); }); expect( - screen.getByRole("button", { name: /Add provider/ }), - ).toBeInTheDocument(); + screen.getAllByRole("button", { name: /New provider…/ }).length, + ).toBeGreaterThan(0); }); it("renders provider list and opens create dialog for admins", async () => { @@ -89,7 +89,7 @@ describe("ServiceProvidersPageClient", () => { }); expect(screen.getByText("dock@example.com")).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: /Add provider/ })); - expect(screen.getByRole("heading", { name: "Add provider" })).toBeVisible(); + await user.click(screen.getByRole("button", { name: /New provider…/ })); + expect(screen.getByRole("heading", { name: "New provider" })).toBeVisible(); }); }); diff --git a/src/__tests__/components/services/services-calendar-page-client.test.tsx b/src/__tests__/components/services/services-calendar-page-client.test.tsx index 7de2a26..7555ca9 100644 --- a/src/__tests__/components/services/services-calendar-page-client.test.tsx +++ b/src/__tests__/components/services/services-calendar-page-client.test.tsx @@ -50,7 +50,6 @@ describe("ServicesCalendarPageClient", () => { await waitFor(() => { expect(screen.getByText("CalendarMonth")).toBeInTheDocument(); }); - expect(screen.getByText("NavTabs")).toBeInTheDocument(); }); it("shows disabled message when service scheduling is off", async () => { @@ -64,7 +63,7 @@ describe("ServicesCalendarPageClient", () => { await waitFor(() => { expect( - screen.getByText(/Service scheduling is disabled/), + screen.getByText(/Service scheduling is turned off/), ).toBeInTheDocument(); }); }); diff --git a/src/__tests__/components/services/services-header.test.tsx b/src/__tests__/components/services/services-header.test.tsx new file mode 100644 index 0000000..a1822a6 --- /dev/null +++ b/src/__tests__/components/services/services-header.test.tsx @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; + +const listScheduledAssetsMock = vi.fn(); +const pathnameMock = vi.fn(() => "/services"); + +vi.mock("next/navigation", () => ({ + usePathname: () => pathnameMock(), +})); + +vi.mock("@/lib/api/service-schedules", () => ({ + listScheduledAssets: () => listScheduledAssetsMock(), +})); + +vi.mock("@/lib/api/app-settings", () => ({ + getAppSettings: () => Promise.resolve({ serviceSchedulingEnabled: true }), +})); + +vi.mock("@/lib/use-today-iso-date", () => ({ + useTodayIsoDate: () => "2026-03-01", +})); + +import { ServicesHeader } from "@/components/services/services-header"; + +function row(id: string, nextServiceDate: string, reminderStartDate: string) { + return { + scheduleId: id, + assetId: `asset-${id}`, + assetName: `Asset ${id}`, + assetTag: `T-${id}`, + assetStatus: "active", + nextServiceDate, + intervalValue: 1, + intervalUnit: "months", + reminderLeadValue: 1, + reminderLeadUnit: "days", + reminderStartDate, + lastServiceDate: null, + lastServiceDescription: null, + lastServiceProviderName: null, + }; +} + +function renderHeader() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe("ServicesHeader", () => { + beforeEach(() => { + listScheduledAssetsMock.mockReset(); + pathnameMock.mockReturnValue("/services"); + }); + + it("counts due this week with the same buckets as the schedule list", async () => { + listScheduledAssetsMock.mockResolvedValue([ + // overdue + row("a", "2026-02-20", "2026-02-19"), + // due in 3 days, reminder already started: this week + row("b", "2026-03-04", "2026-02-25"), + // due in 5 days, reminder starts tomorrow: Later, not this week + row("c", "2026-03-06", "2026-03-05"), + // due in 20 days: this month + row("d", "2026-03-21", "2026-02-21"), + ]); + + renderHeader(); + + expect( + await screen.findByText("1 overdue · 1 due this week · 4 scheduled"), + ).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "Services4", + ); + }); + + it("leaves the count off the title outside the schedule views", async () => { + pathnameMock.mockReturnValue("/services/providers"); + listScheduledAssetsMock.mockResolvedValue([ + row("a", "2026-03-04", "2026-02-25"), + ]); + + renderHeader(); + + expect( + await screen.findByText("1 due this week · 1 scheduled"), + ).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + /^Services$/, + ); + }); +}); diff --git a/src/__tests__/components/services/services-list-page-client.test.tsx b/src/__tests__/components/services/services-list-page-client.test.tsx index f254f8e..e2a5c9b 100644 --- a/src/__tests__/components/services/services-list-page-client.test.tsx +++ b/src/__tests__/components/services/services-list-page-client.test.tsx @@ -50,7 +50,6 @@ describe("ServicesListPageClient", () => { await waitFor(() => { expect(screen.getByText("ScheduledList")).toBeInTheDocument(); }); - expect(screen.getByText("NavTabs")).toBeInTheDocument(); }); it("shows disabled message when service scheduling is off", async () => { @@ -64,9 +63,8 @@ describe("ServicesListPageClient", () => { await waitFor(() => { expect( - screen.getByText(/Service scheduling is disabled/), + screen.getByText(/Service scheduling is turned off/), ).toBeInTheDocument(); }); - expect(screen.getByText("NavTabs")).toBeInTheDocument(); }); }); diff --git a/src/__tests__/components/services/services-nav-tabs.test.tsx b/src/__tests__/components/services/services-nav-tabs.test.tsx index dfa1f99..afd031d 100644 --- a/src/__tests__/components/services/services-nav-tabs.test.tsx +++ b/src/__tests__/components/services/services-nav-tabs.test.tsx @@ -14,9 +14,9 @@ describe("ServicesNavTabs", () => { render(); - const listLink = screen.getByRole("link", { name: "List" }); + const listLink = screen.getByRole("link", { name: "Schedule" }); const calendarLink = screen.getByRole("link", { name: "Calendar" }); - const groupsLink = screen.getByRole("link", { name: "Groups" }); + const groupsLink = screen.getByRole("link", { name: "Service plans" }); const providersLink = screen.getByRole("link", { name: "Providers" }); expect(listLink).toHaveAttribute("href", "/services"); @@ -25,15 +25,15 @@ describe("ServicesNavTabs", () => { expect(providersLink).toHaveAttribute("href", "/services/providers"); }); - it("applies active styling to the matching tab", () => { + it("marks the matching tab as the current page", () => { mockPathname = "/services/groups/group1"; render(); - const groupsLink = screen.getByRole("link", { name: "Groups" }); - expect(groupsLink.className).toContain("bg-background"); + const groupsLink = screen.getByRole("link", { name: "Service plans" }); + expect(groupsLink).toHaveAttribute("aria-current", "page"); - const listLink = screen.getByRole("link", { name: "List" }); - expect(listLink.className).not.toContain("bg-background"); + const listLink = screen.getByRole("link", { name: "Schedule" }); + expect(listLink).not.toHaveAttribute("aria-current"); }); }); diff --git a/src/__tests__/components/services/services-scheduled-list.test.tsx b/src/__tests__/components/services/services-scheduled-list.test.tsx index fdf4515..cde23bf 100644 --- a/src/__tests__/components/services/services-scheduled-list.test.tsx +++ b/src/__tests__/components/services/services-scheduled-list.test.tsx @@ -60,12 +60,12 @@ describe("ServicesScheduledList", () => { expect(screen.getByText("Main Engine")).toBeInTheDocument(); }); expect(screen.getByText("ENG-0001")).toBeInTheDocument(); - // 2026-03-06 is 5 days from mocked today 2026-03-01 → "Due in 5 days" - expect(screen.getByText("Due in 5 days")).toBeInTheDocument(); + // 2026-03-06 is 5 days from mocked today 2026-03-01 + expect(screen.getAllByText("In 5 days").length).toBeGreaterThan(0); // Group header for "Due this week" is shown with a count of 1 expect(screen.getByTestId("services-group-thisWeek")).toBeInTheDocument(); expect( - screen.getByText("Last service 01-02-2026 • Dockside Repair"), + screen.getByText("Last 01-02-2026 by Dockside Repair"), ).toBeInTheDocument(); expect(screen.getByTestId("log-service-schedule1")).toBeInTheDocument(); }); @@ -162,7 +162,7 @@ describe("ServicesScheduledList", () => { expect( within(screen.getByTestId("services-group-upcoming")).getByText("Far"), ).toBeInTheDocument(); - expect(screen.getAllByText(/days overdue/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/^Overdue \d+ days?$/).length).toBeGreaterThan(0); }); it("keeps future reminder-window rows in upcoming", async () => { @@ -191,7 +191,9 @@ describe("ServicesScheduledList", () => { expect(screen.getByTestId("services-group-upcoming")).toBeInTheDocument(); }); expect(screen.queryByTestId("services-group-thisWeek")).toBeNull(); - expect(screen.getByText("Scheduled")).toBeInTheDocument(); + expect( + within(screen.getByTestId("services-group-upcoming")).getByText("Not Ready"), + ).toBeInTheDocument(); }); it("renders empty state when no schedules exist", async () => { diff --git a/src/__tests__/components/settings/user-management-section.test.tsx b/src/__tests__/components/settings/user-management-section.test.tsx index 7c47af5..4aabedc 100644 --- a/src/__tests__/components/settings/user-management-section.test.tsx +++ b/src/__tests__/components/settings/user-management-section.test.tsx @@ -139,6 +139,16 @@ describe("UserManagementSection", () => { ).toHaveLength(2); }); + it("shows a loading state in the table while users load", () => { + listUsersMock.mockReturnValue(new Promise(() => {})); + + renderWithClient(); + + const table = screen.getByTestId("users-table"); + expect(within(table).getByText("Loading users...")).toBeInTheDocument(); + expect(within(table).getAllByRole("row").length).toBeGreaterThan(1); + }); + it("shows an error state when users fail to load", async () => { listUsersMock.mockRejectedValue(new Error("database is gone")); diff --git a/src/__tests__/components/tags/tag-picker.test.tsx b/src/__tests__/components/tags/tag-picker.test.tsx index d50d8d1..20286fa 100644 --- a/src/__tests__/components/tags/tag-picker.test.tsx +++ b/src/__tests__/components/tags/tag-picker.test.tsx @@ -20,10 +20,12 @@ describe("TagPicker", () => { it("shows empty message when no tags defined", () => { render(); - expect(screen.getByText("No tags defined.")).toBeInTheDocument(); + expect( + screen.getByText("No keywords yet. Add them under Taxonomy."), + ).toBeInTheDocument(); }); - it("renders selected tag badges", () => { + it("marks selected keywords as pressed", () => { render( { />, ); - const fragileElements = screen.getAllByText("Fragile"); - expect(fragileElements.length).toBeGreaterThanOrEqual(2); - const expensiveElements = screen.getAllByText("Expensive"); - expect(expensiveElements.length).toBeGreaterThanOrEqual(2); + expect(screen.getByRole("button", { name: /Fragile/ })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(screen.getByRole("button", { name: /Expensive/ })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(screen.getByRole("button", { name: /Heavy/ })).toHaveAttribute( + "aria-pressed", + "false", + ); }); }); diff --git a/src/__tests__/components/tags/tags-page-client.test.tsx b/src/__tests__/components/tags/tags-page-client.test.tsx index 3a2c34d..498515e 100644 --- a/src/__tests__/components/tags/tags-page-client.test.tsx +++ b/src/__tests__/components/tags/tags-page-client.test.tsx @@ -75,12 +75,12 @@ describe("TagsPageClient", () => { ]); renderWithClient(); expect(await screen.findByText("Fragile")).toBeInTheDocument(); - expect(screen.getByText("Tags")).toBeInTheDocument(); + expect(screen.getByText("Keywords")).toBeInTheDocument(); }); it("shows empty state when no tags exist", async () => { listTagsMock.mockResolvedValue([]); renderWithClient(); - expect(await screen.findByText("No tags yet.")).toBeInTheDocument(); + expect(await screen.findByText("No keywords yet.")).toBeInTheDocument(); }); }); diff --git a/src/__tests__/components/ui/button.test.tsx b/src/__tests__/components/ui/button.test.tsx new file mode 100644 index 0000000..8d68abe --- /dev/null +++ b/src/__tests__/components/ui/button.test.tsx @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Button } from "@/components/ui/button"; + +describe("Button", () => { + it("keeps the link variant bare at every size", () => { + for (const size of ["default", "sm", "xs", "lg"] as const) { + const { unmount } = render( + , + ); + const classes = screen.getByRole("button", { name: "Reset" }).className.split(/\s+/); + expect(classes).toContain("h-auto"); + expect(classes).toContain("px-0"); + expect(classes.some((c) => /^h-(7|8|9|11)$/.test(c))).toBe(false); + expect(classes.some((c) => /^px-(2|2\.5|3\.5|4)$/.test(c))).toBe(false); + unmount(); + } + }); + + it("does not drop the focus outline", () => { + render(); + expect(screen.getByRole("button", { name: "Save" }).className).not.toMatch( + /\boutline-none\b/, + ); + }); +}); diff --git a/src/__tests__/lib/relative-time.test.ts b/src/__tests__/lib/relative-time.test.ts new file mode 100644 index 0000000..b11e186 --- /dev/null +++ b/src/__tests__/lib/relative-time.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { formatRelativeTime } from "@/lib/relative-time"; + +// Local-time construction keeps the day maths independent of the TZ. +const NOW = new Date(2026, 10, 7, 15, 30, 0); +const at = (y: number, m: number, d: number, h = 12, min = 0) => + new Date(y, m, d, h, min, 0).getTime(); + +describe("formatRelativeTime", () => { + it("shows today's clock time", () => { + expect(formatRelativeTime(at(2026, 10, 7, 9, 4), NOW)).toEqual({ + long: "Today 09:04", + short: "09:04", + }); + }); + + it("treats a future timestamp as today", () => { + expect(formatRelativeTime(at(2026, 10, 9, 8, 0), NOW).long).toBe( + "Today 08:00", + ); + }); + + it("counts calendar days, not 24-hour spans", () => { + // 23:59 yesterday is under a day ago but still "Yesterday". + expect(formatRelativeTime(at(2026, 10, 6, 23, 59), NOW)).toEqual({ + long: "Yesterday", + short: "1d", + }); + }); + + it("uses days below a week", () => { + expect(formatRelativeTime(at(2026, 10, 1), NOW)).toEqual({ + long: "6 days ago", + short: "6d", + }); + }); + + it("uses weeks below 30 days, singular for one", () => { + expect(formatRelativeTime(at(2026, 9, 31), NOW)).toEqual({ + long: "1 week ago", + short: "1w", + }); + expect(formatRelativeTime(at(2026, 9, 15), NOW)).toEqual({ + long: "3 weeks ago", + short: "3w", + }); + }); + + it("uses months below a year", () => { + expect(formatRelativeTime(at(2026, 9, 7), NOW)).toEqual({ + long: "1 month ago", + short: "1mo", + }); + expect(formatRelativeTime(at(2026, 4, 7), NOW).long).toBe("6 months ago"); + }); + + it("uses years from 365 days", () => { + expect(formatRelativeTime(at(2025, 10, 7), NOW)).toEqual({ + long: "1 year ago", + short: "1y", + }); + expect(formatRelativeTime(at(2023, 10, 1), NOW).short).toBe("3y"); + }); + + it("returns a dash for an invalid timestamp", () => { + expect(formatRelativeTime(Number.NaN, NOW)).toEqual({ + long: "—", + short: "—", + }); + }); +}); diff --git a/src/app/(app)/assets/[id]/edit/page.tsx b/src/app/(app)/assets/[id]/edit/page.tsx index 4f9cd90..c8e1bed 100644 --- a/src/app/(app)/assets/[id]/edit/page.tsx +++ b/src/app/(app)/assets/[id]/edit/page.tsx @@ -9,13 +9,11 @@ export default async function EditAssetPage({ const { id } = await params; return ( -
+
diff --git a/src/app/(app)/assets/[id]/page.tsx b/src/app/(app)/assets/[id]/page.tsx index 1615ffd..bd3c14b 100644 --- a/src/app/(app)/assets/[id]/page.tsx +++ b/src/app/(app)/assets/[id]/page.tsx @@ -1,5 +1,4 @@ import { AssetDetailPageClient } from "@/components/assets/asset-detail-page-client"; -import { PageHeader } from "@/components/layout/page-header"; export default async function AssetDetailPage({ params, @@ -7,19 +6,5 @@ export default async function AssetDetailPage({ params: Promise<{ id: string }>; }) { const { id } = await params; - - return ( -
- - -
- ); + return ; } diff --git a/src/app/(app)/assets/new/page.tsx b/src/app/(app)/assets/new/page.tsx index aaa8601..35ca036 100644 --- a/src/app/(app)/assets/new/page.tsx +++ b/src/app/(app)/assets/new/page.tsx @@ -1,19 +1,17 @@ +import { Suspense } from "react"; import { AssetCreatePageClient } from "@/components/assets/asset-create-page-client"; import { PageHeader } from "@/components/layout/page-header"; export default function NewAssetPage() { return ( -
+
- + + +
); } diff --git a/src/app/(app)/assets/page.tsx b/src/app/(app)/assets/page.tsx index d0f305b..9f0bf1a 100644 --- a/src/app/(app)/assets/page.tsx +++ b/src/app/(app)/assets/page.tsx @@ -1,17 +1,10 @@ import { Suspense } from "react"; import { AssetsPageClient } from "@/components/assets/assets-page-client"; -import { PageHeader } from "@/components/layout/page-header"; export default function AssetsPage() { return ( -
- - - - -
+ + + ); } diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 362e12c..abe749e 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -1,26 +1,38 @@ "use client"; -import { useState } from "react"; +import Link from "next/link"; import { useQuery } from "@tanstack/react-query"; -import { ChevronDown } from "lucide-react"; +import { Plus } from "lucide-react"; import { PageHeader } from "@/components/layout/page-header"; import { DashboardStatsBar } from "@/components/dashboard/dashboard-stats-bar"; import { CategoryBreakdown } from "@/components/dashboard/category-breakdown"; -import { LocationBreakdown } from "@/components/dashboard/location-breakdown"; -import { QuickActions } from "@/components/dashboard/quick-actions"; import { RecentAssetsCard } from "@/components/dashboard/recent-assets-card"; import { UpcomingServicesWidget } from "@/components/dashboard/upcoming-services-widget"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; import { useCurrentUser } from "@/hooks/use-current-user"; -import { useMediaQuery } from "@/hooks/use-media-query"; +import { useHydrated } from "@/hooks/use-hydrated"; import { getDashboardOverview } from "@/lib/api/dashboard"; +function partOfDay(date: Date) { + const hour = date.getHours(); + if (hour < 12) return "Morning"; + if (hour < 18) return "Afternoon"; + return "Evening"; +} + +function todayTitle(date: Date) { + return date.toLocaleDateString("en-GB", { + weekday: "long", + day: "numeric", + month: "long", + }); +} + export default function DashboardPage() { - const isDesktop = useMediaQuery("(min-width: 1024px)"); + // The date and greeting depend on the viewer's clock and time zone, so the + // server renders the loading state and the client fills them in. + const hydrated = useHydrated(); const { data: currentUser } = useCurrentUser(); const { data: overview, @@ -31,134 +43,91 @@ export default function DashboardPage() { queryFn: getDashboardOverview, }); - const greeting = currentUser?.name - ? `Welcome back, ${currentUser.name}` - : "Dashboard"; + const now = hydrated ? new Date() : null; + const title = now ? todayTitle(now) : "Today"; + const firstName = currentUser?.name?.split(/\s+/)[0]; + const actions = ( + + ); - if (isPending) { + if (!now || isPending) { return ( -
- -
- Loading dashboard... -
+
+ +
); } if (isError || !overview) { return ( -
- -
- Could not load the dashboard. Please try again. -
+
+ +

+ Could not load the dashboard. Reload to try again. +

); } - const recent = ( - - ); - const upcoming = ( - - ); - const categoryBreakdown = ( - - ); - const locationBreakdown = ( - + const changedToday = overview.changedLastDayCount; + const overdue = overview.serviceSchedulingEnabled + ? overview.overdueServiceCount + : 0; + + const summary = ( + <> + {partOfDay(now)} + {firstName ? `, ${firstName}` : ""}.{" "} + {overdue > 0 ? ( + <> + + {overdue} {overdue === 1 ? "service" : "services"} overdue + + {changedToday > 0 ? " and " : "."} + + ) : changedToday > 0 ? null : ( + "Nothing is overdue." + )} + {changedToday > 0 ? ( + <> + + {changedToday} {changedToday === 1 ? "asset" : "assets"} changed + {" "} + in the last day. + + ) : null} + ); return ( -
-
- -
- -
-
+
+ -
- -
+ - {isDesktop ? ( -
- {recent} - {upcoming} - {categoryBreakdown} - {locationBreakdown} -
- ) : ( -
- {upcoming} - {recent} - - - {categoryBreakdown} - - - {locationBreakdown} - +
+ +
+ +
- )} +
); } - -function MobileCollapsibleBlock({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}) { - const [open, setOpen] = useState(false); - return ( - - - {title} - - - -
{children}
-
-
- ); -} diff --git a/src/app/(app)/labels/page.tsx b/src/app/(app)/labels/page.tsx index dc9e0de..0330124 100644 --- a/src/app/(app)/labels/page.tsx +++ b/src/app/(app)/labels/page.tsx @@ -1,16 +1,24 @@ +import Link from "next/link"; +import { Printer } from "lucide-react"; import { LabelsPageClient } from "@/components/labels/labels-page-client"; import { PageHeader } from "@/components/layout/page-header"; +import { Button } from "@/components/ui/button"; export default function LabelsPage() { return ( -
+
+ + + Print labels… + + + } />
diff --git a/src/app/(app)/locations/page.tsx b/src/app/(app)/locations/page.tsx index 8722063..b1c564a 100644 --- a/src/app/(app)/locations/page.tsx +++ b/src/app/(app)/locations/page.tsx @@ -1,14 +1,10 @@ -import { PageHeader } from "@/components/layout/page-header"; +import { Suspense } from "react"; import { LocationsPageClient } from "@/components/locations/locations-page-client"; export default function LocationsPage() { return ( -
- + -
+ ); } diff --git a/src/app/(app)/services/calendar/page.tsx b/src/app/(app)/services/calendar/page.tsx index 2c0609e..3d863ee 100644 --- a/src/app/(app)/services/calendar/page.tsx +++ b/src/app/(app)/services/calendar/page.tsx @@ -1,19 +1,7 @@ -import { PageHeader } from "@/components/layout/page-header"; import { ServicesCalendarPageClient } from "@/components/services/services-calendar-page-client"; export default function ServicesCalendarPage() { return ( -
- - -
+ ); } diff --git a/src/app/(app)/services/groups/[id]/page.tsx b/src/app/(app)/services/groups/[id]/page.tsx index 2c0da34..b1553e3 100644 --- a/src/app/(app)/services/groups/[id]/page.tsx +++ b/src/app/(app)/services/groups/[id]/page.tsx @@ -1,4 +1,3 @@ -import { PageHeader } from "@/components/layout/page-header"; import { ServiceGroupDetailPageClient } from "@/components/services/service-group-detail-page-client"; export default async function ServiceGroupDetailPage({ @@ -8,19 +7,5 @@ export default async function ServiceGroupDetailPage({ }) { const { id } = await params; - return ( -
- - -
- ); + return ; } diff --git a/src/app/(app)/services/groups/page.tsx b/src/app/(app)/services/groups/page.tsx index d314786..a6f33ac 100644 --- a/src/app/(app)/services/groups/page.tsx +++ b/src/app/(app)/services/groups/page.tsx @@ -1,19 +1,7 @@ -import { PageHeader } from "@/components/layout/page-header"; import { ServiceGroupsList } from "@/components/services/service-groups-list"; export default function ServiceGroupsPage() { return ( -
- - -
+ ); } diff --git a/src/app/(app)/services/layout.tsx b/src/app/(app)/services/layout.tsx new file mode 100644 index 0000000..dcbae42 --- /dev/null +++ b/src/app/(app)/services/layout.tsx @@ -0,0 +1,10 @@ +import { ServicesHeader } from "@/components/services/services-header"; + +export default function ServicesLayout({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/app/(app)/services/page.tsx b/src/app/(app)/services/page.tsx index 55548a5..c73c284 100644 --- a/src/app/(app)/services/page.tsx +++ b/src/app/(app)/services/page.tsx @@ -1,18 +1,7 @@ -import { PageHeader } from "@/components/layout/page-header"; import { ServicesListPageClient } from "@/components/services/services-list-page-client"; export default function ServicesPage() { return ( -
- - -
+ ); } diff --git a/src/app/(app)/services/providers/page.tsx b/src/app/(app)/services/providers/page.tsx index ec5a708..fa26e66 100644 --- a/src/app/(app)/services/providers/page.tsx +++ b/src/app/(app)/services/providers/page.tsx @@ -1,19 +1,7 @@ -import { PageHeader } from "@/components/layout/page-header"; import { ServiceProvidersPageClient } from "@/components/services/service-providers-page-client"; export default function ServiceProvidersPage() { return ( -
- - -
+ ); } diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 68fc289..3ad10fc 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -3,10 +3,11 @@ import { SettingsPageClient } from "@/components/settings/settings-page-client"; export default function SettingsPage() { return ( -
+
diff --git a/src/app/(app)/taxonomy/page.tsx b/src/app/(app)/taxonomy/page.tsx index 260c1fd..570292b 100644 --- a/src/app/(app)/taxonomy/page.tsx +++ b/src/app/(app)/taxonomy/page.tsx @@ -8,6 +8,7 @@ import { CategoriesPageClient } from "@/components/categories/categories-page-cl import { TagsPageClient } from "@/components/tags/tags-page-client"; import { FieldsPageClient } from "@/components/fields/fields-page-client"; +// "tags" stays the URL and code name; the UI calls them keywords. const VALID_TABS = ["categories", "tags", "fields"] as const; type TabValue = (typeof VALID_TABS)[number]; @@ -26,11 +27,11 @@ function TaxonomyTabs() { } return ( - - + + Categories - Tags - Fields + Keywords + Custom fields @@ -47,14 +48,11 @@ function TaxonomyTabs() { export default function TaxonomyPage() { return ( -
+
diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index 1c36862..25586e5 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -1,11 +1,9 @@ +import { AuthShell } from "@/components/auth/auth-shell"; + export default function AuthLayout({ children, }: { children: React.ReactNode; }) { - return ( -
- {children} -
- ); + return {children}; } diff --git a/src/app/(print)/labels/print/page.tsx b/src/app/(print)/labels/print/page.tsx index 2344a9e..a3f1c91 100644 --- a/src/app/(print)/labels/print/page.tsx +++ b/src/app/(print)/labels/print/page.tsx @@ -3,15 +3,14 @@ import { PageHeader } from "@/components/layout/page-header"; export default function LabelPrintPage() { return ( -
+
diff --git a/src/app/globals.css b/src/app/globals.css index f20f45f..28ff45b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -4,11 +4,21 @@ @custom-variant dark (&:is(.dark *)); +/* + Workshop design system. + + Concrete grey background, charcoal sidebar, one safety-amber accent. + Bright amber is the one primary action per screen and the current item in + the charcoal sidebar. The darker amber is the 3px "where you are" mark on + light surfaces. Ink is what you have ticked. Status colour appears only on + exceptions. Only printable things carry a border. +*/ + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + --font-sans: var(--font-archivo), system-ui, sans-serif; + --font-mono: var(--font-jetbrains), ui-monospace, monospace; --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -16,6 +26,7 @@ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-muted: var(--sidebar-muted); --color-sidebar: var(--sidebar); --color-chart-5: var(--chart-5); --color-chart-4: var(--chart-4); @@ -25,6 +36,7 @@ --color-ring: var(--ring); --color-input: var(--input); --color-border: var(--border); + --color-line-2: var(--line-2); --color-destructive: var(--destructive); --color-scan: var(--scan); --color-scan-foreground: var(--scan-foreground); @@ -36,101 +48,200 @@ --color-secondary: var(--secondary); --color-primary-foreground: var(--primary-foreground); --color-primary: var(--primary); + --color-primary-hover: var(--primary-hover); --color-popover-foreground: var(--popover-foreground); --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); --color-card: var(--card); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --radius-2xl: calc(var(--radius) + 8px); - --radius-3xl: calc(var(--radius) + 12px); - --radius-4xl: calc(var(--radius) + 16px); + --color-ink: var(--foreground); + --color-ink-2: var(--ink-2); + --color-ink-3: var(--muted-foreground); + --color-amber: var(--primary); + --color-amber-ink: var(--amber-ink); + --color-chip: var(--chip); + --color-repair: var(--repair); + --color-slate: var(--slate); + --color-retired: var(--retired); + --color-disposed: var(--disposed); + --radius-xs: 2px; + --radius-sm: 2px; + --radius-md: 3px; + --radius-lg: 3px; + --radius-xl: 4px; + --radius-2xl: 6px; + --radius-3xl: 8px; + --radius-4xl: 12px; + --breakpoint-sm: 640px; + --breakpoint-md: 768px; + --breakpoint-lg: 1024px; + --breakpoint-xl: 1280px; + --breakpoint-2xl: 1536px; } :root { - --radius: 0.625rem; - --background: #fafaf9; - --foreground: #1c1917; - --card: #ffffff; - --card-foreground: #1c1917; - --popover: #ffffff; - --popover-foreground: #1c1917; - --primary: #c2410c; - --primary-foreground: #ffffff; - --secondary: #f5f0e8; - --secondary-foreground: #1c1917; - --muted: #f5f5f4; - --muted-foreground: #78716c; - --accent: #fff7ed; - --accent-foreground: #1c1917; - --destructive: oklch(0.577 0.245 27.325); - --border: #e7e5e4; - --input: #e7e5e4; - --ring: #c2410c; - --chart-1: #c2410c; - --chart-2: #d97706; - --chart-3: #6b8e6b; - --chart-4: #a0826d; - --chart-5: #c2b280; - --sidebar: #faf8f5; - --sidebar-foreground: #1c1917; - --sidebar-primary: #c2410c; - --sidebar-primary-foreground: #ffffff; - --sidebar-accent: #f5efe8; - --sidebar-accent-foreground: #1c1917; - --sidebar-border: #e7e5e4; - --sidebar-ring: #c2410c; - --scan: #c2410c; - --scan-foreground: #ffffff; + --radius: 3px; + --background: #ecede9; + --foreground: #1b1d1a; + --ink-2: #4e524b; + --card: #fbfbf9; + --card-foreground: #1b1d1a; + --popover: #fbfbf9; + --popover-foreground: #1b1d1a; + --primary: #e8a80c; + --primary-hover: #d39a08; + --primary-foreground: #1b1d1a; + --amber-ink: #a87700; + --secondary: #f2f2ee; + --secondary-foreground: #1b1d1a; + --muted: #f2f2ee; + --muted-foreground: #666a62; + --accent: #f2f2ee; + --accent-foreground: #1b1d1a; + --chip: #e1e2dc; + --destructive: #b3261e; + --repair: #a4521a; + --slate: #4f6475; + --retired: #6b7066; + --disposed: #a3252d; + --border: #d9dad4; + --line-2: #bdbfb7; + --input: #7a7e76; + --ring: #1b1d1a; + --chart-1: #1b1d1a; + --chart-2: #3a3d39; + --chart-3: #595d56; + --chart-4: #777a72; + --chart-5: #8e918a; + --sidebar: #1f2220; + --sidebar-foreground: #d9dbd5; + --sidebar-muted: #9a9e96; + --sidebar-primary: #e8a80c; + --sidebar-primary-foreground: #1b1d1a; + --sidebar-accent: #2a2d2a; + --sidebar-accent-foreground: #ffffff; + --sidebar-border: #33372f; + --sidebar-ring: #ffffff; + --scan: #e8a80c; + --scan-foreground: #1b1d1a; } .dark { - --background: #0c0a09; - --foreground: #fafaf9; - --card: #1c1917; - --card-foreground: #fafaf9; - --popover: #1c1917; - --popover-foreground: #fafaf9; - --primary: #ea580c; - --primary-foreground: #ffffff; - --secondary: #292524; - --secondary-foreground: #fafaf9; - --muted: #292524; - --muted-foreground: #a8a29e; - --accent: #292524; - --accent-foreground: #fafaf9; - --destructive: oklch(0.704 0.191 22.216); - --border: #44403c; - --input: #44403c; - --ring: #ea580c; - --chart-1: #ea580c; - --chart-2: #f59e0b; - --chart-3: #86a886; - --chart-4: #b8977e; - --chart-5: #d4c99a; - --sidebar: #1c1917; - --sidebar-foreground: #fafaf9; - --sidebar-primary: #ea580c; - --sidebar-primary-foreground: #ffffff; - --sidebar-accent: #292524; - --sidebar-accent-foreground: #fafaf9; - --sidebar-border: #44403c; - --sidebar-ring: #ea580c; - --scan: #ea580c; - --scan-foreground: #ffffff; + --background: #1b1d1a; + --foreground: #ecede9; + --ink-2: #c9ccc5; + --card: #242725; + --card-foreground: #ecede9; + --popover: #242725; + --popover-foreground: #ecede9; + --primary: #e8a80c; + --primary-hover: #f0b526; + --primary-foreground: #1b1d1a; + --amber-ink: #e8a80c; + --secondary: #2a2d2a; + --secondary-foreground: #ecede9; + --muted: #2a2d2a; + --muted-foreground: #9a9e96; + --accent: #2a2d2a; + --accent-foreground: #ecede9; + --chip: #2f3230; + --destructive: #e5534b; + --repair: #e08a4a; + --slate: #8fa6b8; + --retired: #9a9e96; + --disposed: #e5534b; + --border: #33372f; + --line-2: #4a4e48; + --input: #7a7e76; + --ring: #ecede9; + --chart-1: #ecede9; + --chart-2: #c9ccc5; + --chart-3: #9a9e96; + --chart-4: #777a72; + --chart-5: #595d56; + --sidebar: #141615; + --sidebar-foreground: #d9dbd5; + --sidebar-muted: #9a9e96; + --sidebar-primary: #e8a80c; + --sidebar-primary-foreground: #1b1d1a; + --sidebar-accent: #202321; + --sidebar-accent-foreground: #ffffff; + --sidebar-border: #2a2d2a; + --sidebar-ring: #ffffff; + --scan: #e8a80c; + --scan-foreground: #1b1d1a; } @layer base { * { - @apply border-border outline-ring/50; + @apply border-border; + } + html { + -webkit-font-smoothing: antialiased; } body { - @apply bg-background text-foreground; + @apply bg-background text-sm text-foreground; + line-height: 1.45; + } + /* Focus: a 2px ink outline plus a 3px ink offset shadow. White on charcoal. */ + :focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + box-shadow: 3px 3px 0 var(--ring); + } + .on-charcoal :focus-visible { + outline-color: #fff; + box-shadow: 3px 3px 0 #fff; } } +/* + Unlayered on purpose: it beats `outline-none` (outline-style: none) from the + utilities layer, so no control can drop the keyboard focus outline, and the + outline (unlike the shadow) survives forced-colors mode. Containers that + should not show it use `outline-hidden`, whose transparent colour stays. +*/ +:focus-visible { + outline-style: solid; +} + +/* Archivo set wide for headings. */ +@utility wide { + font-stretch: 118%; + letter-spacing: -0.01em; +} + +/* 13px tracked-caps mono eyebrow (NEXT SERVICE, LABEL, STATUS). */ +@utility eyebrow { + font-family: var(--font-jetbrains), "JetBrains Mono", ui-monospace, monospace; + font-size: 13px; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted-foreground); +} + +/* The one heavy rule: under page titles, table heads and sheet headings. */ +@utility rule-ink { + border-bottom: 2px solid var(--foreground); +} + +/* Long-dash (6 on, 4 off) control-border frame for "add something here". */ +@utility dash-slot { + border: 0; + background: transparent + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='1' y='1' width='calc(100%25 - 2px)' height='calc(100%25 - 2px)' fill='none' rx='3' stroke='%237A7E76' stroke-width='1' stroke-dasharray='6 4'/%3E%3C/svg%3E") + no-repeat; + background-size: 100% 100%; +} + +/* Picker runs: a dotted underline so a control never looks like a link. */ +@utility picker-run { + text-decoration: underline dotted; + text-decoration-thickness: 1.5px; + text-underline-offset: 3px; + text-decoration-color: var(--foreground); +} + @keyframes scan-detected { 0% { opacity: 0; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d83059e..5286c2a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,19 +1,21 @@ import type { Metadata, Viewport } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Archivo, JetBrains_Mono } from "next/font/google"; import { PocketBaseClientProvider } from "@/app/PocketBaseClientProvider"; import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", +const archivo = Archivo({ + variable: "--font-archivo", subsets: ["latin"], + axes: ["wdth"], }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", +const jetbrainsMono = JetBrains_Mono({ + variable: "--font-jetbrains", subsets: ["latin"], + weight: ["500", "600", "700"], }); export const metadata: Metadata = { @@ -23,7 +25,7 @@ export const metadata: Metadata = { appleWebApp: { capable: true, title: "Stowage", - statusBarStyle: "default", + statusBarStyle: "black", startupImage: ["/images/web/apple-touch-icon.png"], }, icons: { @@ -56,7 +58,7 @@ export const metadata: Metadata = { { rel: "mask-icon", url: "/images/web/safari-pinned-tab.svg", - color: "#c2410c", + color: "#1f2220", }, ], }, @@ -67,8 +69,8 @@ export const viewport: Viewport = { initialScale: 1, viewportFit: "cover", themeColor: [ - { media: "(prefers-color-scheme: light)", color: "#c2410c" }, - { media: "(prefers-color-scheme: dark)", color: "#ea580c" }, + { media: "(prefers-color-scheme: light)", color: "#1f2220" }, + { media: "(prefers-color-scheme: dark)", color: "#141615" }, ], }; @@ -83,13 +85,13 @@ export default async function RootLayout({ return ( {children} - + diff --git a/src/components/assets/asset-actions-sheet.tsx b/src/components/assets/asset-actions-sheet.tsx index c6db613..b57f304 100644 --- a/src/components/assets/asset-actions-sheet.tsx +++ b/src/components/assets/asset-actions-sheet.tsx @@ -630,7 +630,7 @@ function LogServiceView({ toast.success("Service logged"); onDone(); void queryClient.invalidateQueries({ - queryKey: ["service-records", "asset", asset.id], + queryKey: ["service-records", "by-asset", asset.id], }); void queryClient.invalidateQueries({ queryKey: ["service-schedules"] }); void queryClient.invalidateQueries({ queryKey: ["dashboard"] }); diff --git a/src/components/assets/asset-card-list.tsx b/src/components/assets/asset-card-list.tsx index bdecc01..e54c478 100644 --- a/src/components/assets/asset-card-list.tsx +++ b/src/components/assets/asset-card-list.tsx @@ -1,17 +1,18 @@ "use client"; import { useMemo, useState } from "react"; -import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; -import { MapPin, MoreHorizontal, Package } from "lucide-react"; +import { MoreHorizontal, Package } from "lucide-react"; import { AssetActionsSheet } from "@/components/assets/asset-actions-sheet"; -import { StatusBadge } from "@/components/assets/status-badge"; import type { AssetListItem } from "@/components/assets/types"; -import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; -import { Skeleton } from "@/components/ui/skeleton"; +import { StatusText } from "@/components/ui/status-text"; +import { TagChip } from "@/components/ui/tag-chip"; +import { LocationPath } from "@/components/locations/location-path"; +import { useLongPress } from "@/hooks/use-long-press"; import { getAsset, type AssetDetail } from "@/lib/api/assets"; -import { cn } from "@/lib/utils"; +import { formatRelativeTime } from "@/lib/relative-time"; function rowToDetailPlaceholder(row: AssetListItem): AssetDetail { const tags = @@ -51,12 +52,21 @@ function rowToDetailPlaceholder(row: AssetListItem): AssetDetail { }; } +/* + Phone list: stacked cards on one card surface. Line one is the tag chip and + the name; line two is "path · time" on every row; exception statuses take a + 13px third line. Quick actions open from the "…" button on each card, or + by pressing and holding the card. +*/ export function AssetCardList({ rows, loading, + secondary = "location", }: { rows: AssetListItem[]; loading: boolean; + /* What line two leads with; inside one location the category says more. */ + secondary?: "location" | "category"; }) { const [activeId, setActiveId] = useState(null); @@ -69,23 +79,25 @@ export function AssetCardList({ queryKey: ["assets", "detail", activeId], queryFn: () => (activeId ? getAsset(activeId) : Promise.resolve(null)), enabled: !!activeId, - // Seed the sheet from the list row so it opens instantly instead of - // showing a blank closed state until the detail fetch completes. The - // background refetch fills in fields the list row doesn't carry. placeholderData: activeRow ? rowToDetailPlaceholder(activeRow) : undefined, }); if (loading && rows.length === 0) { return ( -
    - {Array.from({ length: 4 }).map((_, i) => ( +
      + {Array.from({ length: 5 }).map((_, i) => (
    • - - - + +
      + + +
    • ))}
    @@ -96,18 +108,25 @@ export function AssetCardList({ return ( ); } return ( <> -
      +
        {rows.map((row) => ( -
      • - setActiveId(row.id)} /> +
      • + setActiveId(row.id)} + />
      • ))}
      @@ -120,7 +139,7 @@ export function AssetCardList({ onAssetUpdated={() => { // list invalidates via the nested mutations' query invalidation }} - dismissLabel="Close ↓" + dismissLabel="Close" testIdPrefix="asset" /> @@ -129,84 +148,76 @@ export function AssetCardList({ function AssetCard({ row, - onMore, + secondary, + onActions, }: { row: AssetListItem; - onMore: () => void; + secondary: "location" | "category"; + onActions: () => void; }) { - const visibleTags = row.tagNames.slice(0, 3); - const extraTagCount = Math.max(0, row.tagNames.length - visibleTags.length); + const router = useRouter(); + const { handlers, consumeClick } = useLongPress(onActions); + const time = formatRelativeTime(row.updatedAt); return ( -
      -
      -
      -
      - {row.categoryColor ? ( -
      -

      - {row.assetTag} -

      - {row.locationPath ? ( -

      -

      - ) : null} - {visibleTags.length > 0 ? ( -
      - {visibleTags.map((name) => ( - - {name} - - ))} - {extraTagCount > 0 ? ( - - +{extraTagCount} - - ) : null} -
      - ) : null} -
      -
      - - -
      -
      + ); } diff --git a/src/components/assets/asset-create-page-client.tsx b/src/components/assets/asset-create-page-client.tsx index d20f1d5..04e3e50 100644 --- a/src/components/assets/asset-create-page-client.tsx +++ b/src/components/assets/asset-create-page-client.tsx @@ -1,8 +1,9 @@ "use client"; import { useMemo, useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check } from "lucide-react"; import { toast } from "sonner"; import { AttachmentsPanel } from "@/components/attachments/attachments-panel"; import { @@ -18,6 +19,9 @@ import type { } from "@/components/assets/types"; import type { FieldDefinition } from "@/components/fields/types"; import { Button } from "@/components/ui/button"; +import { Panel, PanelBody, PanelFooter } from "@/components/ui/panel"; +import { SheetHeading } from "@/components/ui/sheet-heading"; +import { Skeleton } from "@/components/ui/skeleton"; import { getAppSettings } from "@/lib/api/app-settings"; import { createAsset, getAssetFilterOptions } from "@/lib/api/assets"; import { listCustomFields } from "@/lib/api/custom-fields"; @@ -37,6 +41,12 @@ const INITIAL_VALUES: AssetFormValues = { export function AssetCreatePageClient() { const router = useRouter(); const queryClient = useQueryClient(); + // "Add asset here…" on the Locations page presets the location. + const presetLocationId = useSearchParams().get("location"); + const initialValues = useMemo( + () => ({ ...INITIAL_VALUES, locationId: presetLocationId }), + [presetLocationId], + ); const filterOptionsQuery = useQuery({ queryKey: ["assets", "filter-options"], @@ -129,62 +139,70 @@ export function AssetCreatePageClient() { if (loading) { return ( -
      - Loading form... +
      + Loading form... + +
      ); } if (createdAssetId) { return ( -
      -
      -

      - {createdAssetName} created -

      -

      - Upload attachments now, then continue to the asset page. -

      -
      +
      + + +

      +

      +

      + {createdAssetName} +

      +

      + Upload attachments now, then continue to the asset page. +

      +
      + -
      -
      - -
      -
      -

      - Attachments -

      -

      - Upload files now. Images and PDFs are optimized in the background. -

      -
      + + + +
      + Attachments} + /> +

      + Files upload straight away. Images and PDFs are optimized in the + background. +

      @@ -192,25 +210,26 @@ export function AssetCreatePageClient() { } return ( -
      - - setServiceScheduleDraft(getDefaultServiceScheduleDraft()) - } - onSubmit={handleSubmit} - /> -
      + + setServiceScheduleDraft(getDefaultServiceScheduleDraft()) + } + onSubmit={handleSubmit} + /> ); } diff --git a/src/components/assets/asset-detail-page-client.tsx b/src/components/assets/asset-detail-page-client.tsx index 7c4aa71..295ab29 100644 --- a/src/components/assets/asset-detail-page-client.tsx +++ b/src/components/assets/asset-detail-page-client.tsx @@ -13,8 +13,14 @@ import type { } from "@/components/assets/types"; import type { FieldDefinition } from "@/components/fields/types"; import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; import { useCurrentUser } from "@/hooks/use-current-user"; -import { deleteAsset, getAsset, updateAssetStatus } from "@/lib/api/assets"; +import { + deleteAsset, + getAsset, + updateAsset, + updateAssetStatus, +} from "@/lib/api/assets"; import { listCustomFields } from "@/lib/api/custom-fields"; export function AssetDetailPageClient({ assetId }: { assetId: string }) { @@ -46,6 +52,23 @@ export function AssetDetailPageClient({ assetId }: { assetId: string }) { }, }); + const updateLocationMutation = useMutation({ + mutationFn: (locationId: string | null) => + updateAsset(assetId, { locationId }), + onSuccess: () => { + toast.success("Location updated"); + void queryClient.invalidateQueries({ + queryKey: ["assets", "detail", assetId], + }); + void queryClient.invalidateQueries({ queryKey: ["assets", "list"] }); + void queryClient.invalidateQueries({ queryKey: ["locations"] }); + void queryClient.invalidateQueries({ queryKey: ["dashboard"] }); + }, + onError: (error) => { + toast.error(getAssetUiErrorMessage(error, "Unable to move asset")); + }, + }); + const deleteMutation = useMutation({ mutationFn: () => deleteAsset(assetId), onSuccess: () => { @@ -82,22 +105,30 @@ export function AssetDetailPageClient({ assetId }: { assetId: string }) { if (loading) { return ( -
      - Loading asset... +
      + Loading asset... +
      +
      +
      + + + +
      +
      +
      ); } if (!detail) { return ( -
      -

      - Asset not found -

      -

      - The requested asset could not be found. +

      +

      Asset not found

      +

      + It may have been deleted, or the link is wrong. Search for it, or go + back to the list.

      -
      @@ -111,7 +142,13 @@ export function AssetDetailPageClient({ assetId }: { assetId: string }) { canDelete={currentUser?.role === "admin"} deleting={deleteMutation.isPending} updatingStatus={updateStatusMutation.isPending} + updatingLocation={updateLocationMutation.isPending} onStatusChange={handleStatusChange} + onLocationChange={(locationId) => { + if (locationId !== detail.locationId) { + updateLocationMutation.mutate(locationId); + } + }} onDelete={handleDelete} /> ); diff --git a/src/components/assets/asset-detail.tsx b/src/components/assets/asset-detail.tsx index 7629687..4cc3223 100644 --- a/src/components/assets/asset-detail.tsx +++ b/src/components/assets/asset-detail.tsx @@ -2,20 +2,35 @@ import Link from "next/link"; import { useMemo, useState } from "react"; -import { MapPin, MoreHorizontal, Pencil, Printer, Trash2 } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { + ArrowLeft, + ChevronDown, + MapPin, + MoreHorizontal, + Pencil, + Printer, + Trash2, +} from "lucide-react"; import type { FieldDefinition } from "@/components/fields/types"; import { DynamicFieldDisplay } from "@/components/fields/dynamic-field-display"; import { ConfirmDialog } from "@/components/crud/confirm-dialog"; import { AttachmentsPanel } from "@/components/attachments/attachments-panel"; import { AssetServiceRecordsPanel } from "@/components/services/asset-service-records-panel"; -import { StatusBadge } from "@/components/assets/status-badge"; +import { NextServicePanel } from "@/components/services/next-service-panel"; +import { AssetLabelPanel } from "@/components/labels/asset-label-panel"; +import { AssetPhotoSlot } from "@/components/assets/asset-photo-slot"; import { ASSET_STATUS_LABELS, ASSET_STATUS_OPTIONS, type AssetDetail as AssetDetailType, type AssetStatus, } from "@/components/assets/types"; -import { Badge } from "@/components/ui/badge"; +import { MobileBar } from "@/components/layout/mobile-top-bar"; +import { + LocationPicker, + type LocationPickerOption, +} from "@/components/locations/location-picker"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -24,29 +39,90 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Panel } from "@/components/ui/panel"; import { Select, SelectContent, SelectItem, SelectTrigger, - SelectValue, } from "@/components/ui/select"; +import { STATUS_COLOR } from "@/components/ui/status-text"; +import { KeywordChip, TagChip } from "@/components/ui/tag-chip"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; +import { listAttachments } from "@/lib/api/attachments"; +import { listLocations } from "@/lib/api/locations"; import { formatDateFromTimestamp } from "@/lib/date-format"; +import { formatRelativeTime } from "@/lib/relative-time"; import { useAppDateFormat } from "@/lib/use-app-date-format"; +import { cn } from "@/lib/utils"; + +const pickerRun = + "inline-flex h-8 max-w-full cursor-pointer items-center gap-1.5 rounded-xs text-[15px] font-medium outline-none hover:text-foreground disabled:cursor-default disabled:opacity-60"; + +function AssetMenu({ + asset, + canDelete, + deleting, + onDelete, + trigger, +}: { + asset: AssetDetailType; + canDelete: boolean; + deleting: boolean; + onDelete: () => void; + trigger: React.ReactElement; +}) { + return ( + + {trigger} + + + + + Edit + + + + + + Print label + + + {canDelete ? ( + <> + + + + Delete… + + + ) : null} + + + ); +} +/* + The asset page. A hero (photo slot, the tag chip at its heaviest, the name, + then status and location as picker runs and the keywords), the 2px rule, + then two columns: the tabbed details card and the service history sheet on + the left; Next service and the label on the right. On a phone the order is + Next service, details, history, label. +*/ export function AssetDetail({ asset, fieldDefinitions, canDelete, deleting, updatingStatus, + updatingLocation = false, onStatusChange, + onLocationChange, onDelete, }: { asset: AssetDetailType; @@ -54,73 +130,165 @@ export function AssetDetail({ canDelete: boolean; deleting: boolean; updatingStatus: boolean; + updatingLocation?: boolean; onStatusChange: (status: AssetStatus) => void; + onLocationChange?: (locationId: string | null) => void; onDelete: () => Promise; }) { const dateFormat = useAppDateFormat(); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const locationsQuery = useQuery({ + queryKey: ["locations", "list"], + queryFn: listLocations, + enabled: Boolean(onLocationChange), + }); + const attachmentsQuery = useQuery({ + queryKey: ["attachments", asset.id], + queryFn: () => listAttachments(asset.id), + }); + const attachmentCount = attachmentsQuery.data?.length ?? 0; + const orderedFieldDefinitions = useMemo( () => fieldDefinitions.slice().sort((a, b) => a.sortOrder - b.sortOrder), [fieldDefinitions], ); + const locationParts = asset.location?.path.split(" / ") ?? []; + const locationLeaf = locationParts.pop(); + const locationAncestors = locationParts.join(" / "); + + const labelAsset = useMemo( + () => ({ + id: asset.id, + name: asset.name, + assetTag: asset.assetTag, + categoryName: asset.category?.name ?? null, + locationPath: asset.location?.path ?? null, + notes: asset.notes, + customFieldValues: asset.customFieldValues, + }), + [asset], + ); + + const updated = formatRelativeTime(asset.updatedAt); + + const locationTrigger = ( + + ); + + const locationRun = onLocationChange ? ( + + ) : asset.location ? ( + + + ) : null; + return ( -
      - {/* Hero profile card */} -
      -
      -
      -

      - {asset.name} -

      -
      - - {asset.assetTag} - - - {asset.category ? ( - - - {asset.category.name} - - ) : null} - {asset.location ? ( - - - {asset.location.path} - - ) : null} -
      - {asset.serviceGroup ? ( -

      - Service group: {asset.serviceGroup.name} -

      - ) : null} -

      - Created {formatDateFromTimestamp(asset.createdAt, dateFormat)} -

      -
      +
      + {asset.assetTag} + } + actions={ + <> + + + + setConfirmDeleteOpen(true)} + trigger={ + + } + /> + + } + /> -
      + +
      - +
      + + setConfirmDeleteOpen(true)} + trigger={ + + } + /> +
      + - {/* Tabbed content */} -
      - - - Details - Attachments - +
      +
      + + + + Details + + Attachments + {attachmentCount > 0 ? ( + + {attachmentCount} + + ) : null} + + - -
      - {/* Tags */} - {asset.tags.length > 0 ? ( -
      -

      - Tags -

      -
      - {asset.tags.map((tag) => ( - - - {tag.name} - - ))} + + {orderedFieldDefinitions.length > 0 || asset.serviceGroup ? ( +
      +
      + {orderedFieldDefinitions.map((definition) => ( +
      +
      {definition.name}
      +
      + +
      +
      + ))} + {asset.serviceGroup ? ( +
      +
      Service plan
      +
      + {asset.serviceGroup.name} +
      +
      + ) : null} +
      + ) : null} + +
      +

      Notes

      +

      + {asset.notes ?? "No notes"} +

      - ) : null} +
      - {/* Notes */} -
      -

      - Notes -

      -

      - {asset.notes ?? "No notes"} -

      -
      + + + + + - {/* Custom fields */} - {orderedFieldDefinitions.length > 0 ? ( -
      -

      - Custom fields -

      -
      - {orderedFieldDefinitions.map((definition) => ( -
      -

      - {definition.name} -

      -

      - -

      -
      - ))} -
      -
      - ) : null} -
      - +
      + +
      + +

      + Created {formatDateFromTimestamp(asset.createdAt, dateFormat)}.{" "} + + Last changed {updated.long} + {updated.short}. + +

      +
      - - - - -
      + +
      { diff --git a/src/components/assets/asset-edit-page-client.tsx b/src/components/assets/asset-edit-page-client.tsx index 75ef42d..e8362d9 100644 --- a/src/components/assets/asset-edit-page-client.tsx +++ b/src/components/assets/asset-edit-page-client.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; +import { PackageX } from "lucide-react"; import { useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -20,6 +21,9 @@ import type { } from "@/components/assets/types"; import type { FieldDefinition } from "@/components/fields/types"; import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/ui/empty-state"; +import { SheetHeading } from "@/components/ui/sheet-heading"; +import { Skeleton } from "@/components/ui/skeleton"; import { getAppSettings } from "@/lib/api/app-settings"; import { getAsset, getAssetFilterOptions, updateAsset } from "@/lib/api/assets"; import { listCustomFields } from "@/lib/api/custom-fields"; @@ -191,60 +195,67 @@ export function AssetEditPageClient({ assetId }: { assetId: string }) { if (loading) { return ( -
      - Loading asset... +
      + Loading asset... + +
      ); } if (!detail || !initialValues) { return ( -
      -

      - Asset not found -

      -

      - The asset may have been deleted. -

      - -
      + + Back to assets + + } + /> ); } return ( -
      -
      - - setServiceScheduleDraft(getDefaultServiceScheduleDraft()) - } - onSubmit={handleSubmit} - /> -
      +
      + + setServiceScheduleDraft(getDefaultServiceScheduleDraft()) + } + onSubmit={handleSubmit} + /> -
      -
      -

      Attachments

      -

      - Upload files immediately. Images and PDFs are optimized in the - background. -

      -
      +
      + Attachments} + /> +

      + Files upload straight away. Images and PDFs are optimized in the + background. +

      diff --git a/src/components/assets/asset-filters-mobile.tsx b/src/components/assets/asset-filters-mobile.tsx index f429f67..18c9fcd 100644 --- a/src/components/assets/asset-filters-mobile.tsx +++ b/src/components/assets/asset-filters-mobile.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Check, Search, SlidersHorizontal, X } from "lucide-react"; +import { Check, ChevronDown, Search, SlidersHorizontal, X } from "lucide-react"; import { ASSET_STATUS_LABELS, ASSET_STATUS_OPTIONS, @@ -13,7 +13,6 @@ import type { } from "@/components/assets/asset-table"; import type { AssetFiltersState } from "@/components/assets/asset-filters"; import { MobileActionSheet } from "@/components/layout/mobile-action-sheet"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; @@ -56,39 +55,62 @@ export function AssetFiltersMobile({ const activeCount = countActiveFilters(filters, search); return ( -
      +
      -
      - - onSearchChange(event.target.value)} - placeholder="Search assets" - className="pl-9" - aria-label="Search assets" - data-testid="asset-filters-mobile-search" - /> -
      - +
      + {search.trim() ? ( +
      + Searching for “{search.trim()}” + +
      + ) : null} { @@ -115,10 +139,14 @@ function AssetFiltersSheet({ filters, sortBy, sortDirection, + search, + onSearchChange, onFiltersChange, onSortChange, onReset, }: { + search: string; + onSearchChange: (value: string) => void; open: boolean; onOpenChange: (open: boolean) => void; options: AssetFilterOptions; @@ -134,16 +162,32 @@ function AssetFiltersSheet({ open={open} onOpenChange={onOpenChange} title="Filters" - description="Narrow down the asset list." >
      +
      + + onSearchChange(event.target.value)} + placeholder="Search by name, tag or serial" + className="h-11 pl-9" + aria-label="Search assets" + data-testid="asset-filters-mobile-search" + /> +
      + onSortChange("updatedAt", "desc")} + testId="asset-sort-updated" + /> onSortChange("createdAt", "desc")} testId="asset-sort-recent" /> @@ -198,7 +242,6 @@ function AssetFiltersSheet({ key={category.id} active={filters.categoryId === category.id} label={category.name} - dotColor={category.color} onClick={() => onFiltersChange({ ...filters, categoryId: category.id }) } @@ -221,7 +264,7 @@ function AssetFiltersSheet({ ) : null} {options.tags.length > 0 ? ( -
      +
      {options.tags.map((tag) => { const active = filters.tagIds.includes(tag.id); return ( @@ -229,7 +272,6 @@ function AssetFiltersSheet({ key={tag.id} active={active} label={tag.name} - dotColor={tag.color} onClick={() => onFiltersChange({ ...filters, @@ -250,20 +292,23 @@ function AssetFiltersSheet({
      @@ -279,9 +324,7 @@ function Section({ }) { return (
      -

      - {title} -

      +

      {title}

      {children}
      ); @@ -307,10 +350,10 @@ function OptionPill({ data-testid={testId} aria-pressed={active} className={cn( - "inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors", + "inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-md border-[1.5px] px-3 text-sm font-medium transition-colors", active - ? "border-primary bg-primary/10 text-primary" - : "border-border bg-card text-muted-foreground hover:bg-accent", + ? "border-foreground bg-foreground text-background" + : "border-input bg-card text-foreground hover:bg-secondary", )} > {dotColor ? ( @@ -344,10 +387,10 @@ function SortRow({ data-testid={testId} aria-pressed={active} className={cn( - "rounded-full border px-3 py-1.5 text-xs font-medium transition-colors", + "h-9 cursor-pointer rounded-md border-[1.5px] px-3 text-sm font-medium transition-colors", active - ? "border-primary bg-primary/10 text-primary" - : "border-border bg-card text-muted-foreground hover:bg-accent", + ? "border-foreground bg-foreground text-background" + : "border-input bg-card text-foreground hover:bg-secondary", )} > {label} @@ -378,8 +421,8 @@ function LocationList({ className={cn( "flex w-full items-center justify-between border-b border-border px-3 py-2 text-left text-sm last:border-b-0", selectedId === null - ? "bg-primary/5 font-medium text-primary" - : "hover:bg-accent", + ? "bg-secondary font-semibold text-foreground shadow-[inset_3px_0_0_var(--foreground)]" + : "hover:bg-secondary", )} > All locations @@ -396,7 +439,9 @@ function LocationList({ aria-pressed={active} className={cn( "flex w-full flex-col border-b border-border px-3 py-2 text-left last:border-b-0", - active ? "bg-primary/5 text-primary" : "hover:bg-accent", + active + ? "bg-secondary font-semibold shadow-[inset_3px_0_0_var(--foreground)]" + : "hover:bg-secondary", )} > {loc.name} diff --git a/src/components/assets/asset-filters.tsx b/src/components/assets/asset-filters.tsx index e5c98c3..3138427 100644 --- a/src/components/assets/asset-filters.tsx +++ b/src/components/assets/asset-filters.tsx @@ -1,24 +1,13 @@ "use client"; import { useMemo, useState } from "react"; -import { Check, ChevronDown, Filter, Search, X } from "lucide-react"; -import { - ASSET_STATUS_LABELS, - ASSET_STATUS_OPTIONS, - type AssetFilterOptions, - type AssetStatus, -} from "@/components/assets/types"; +import { Check, ChevronDown, Search, SlidersHorizontal, X } from "lucide-react"; +import type { AssetFilterOptions, AssetStatus } from "@/components/assets/types"; import { LocationPicker, type LocationPickerOption, } from "@/components/locations/location-picker"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; import { Command, CommandEmpty, @@ -47,21 +36,25 @@ export type AssetFiltersState = { status: AssetStatus | null; locationId: string | null; tagIds: string[]; + serviceGroupId?: string | null; +}; + +export const EMPTY_FILTERS: AssetFiltersState = { + categoryId: null, + status: null, + locationId: null, + tagIds: [], + serviceGroupId: null, }; function removeTagId(tagIds: string[], tagId: string) { return tagIds.filter((candidate) => candidate !== tagId); } -function addTagId(tagIds: string[], tagId: string) { - if (tagIds.includes(tagId)) { - return tagIds; - } +const triggerClass = + "flex h-9 min-w-32 cursor-pointer items-center justify-between gap-2 rounded-md border-[1.5px] border-input bg-card px-3 text-sm font-medium text-foreground outline-none hover:bg-secondary data-[state=open]:border-foreground"; - return [...tagIds, tagId]; -} - -function TagsPicker({ +function KeywordsPicker({ id, labelledBy, tags, @@ -84,31 +77,24 @@ function TagsPicker({ type="button" id={id} aria-labelledby={labelledBy} - className="flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 text-sm outline-none transition-colors data-[state=open]:border-ring data-[state=open]:ring-[3px] data-[state=open]:ring-ring/40" + aria-label={labelledBy ? undefined : "Filter by keywords"} + className={triggerClass} > - 0 ? "text-foreground" : "text-muted-foreground", - )} - > + {selectedCount === 0 - ? "All tags" + ? "Keywords" : selectedCount === 1 - ? (tags.find((t) => t.id === selectedIds[0])?.name ?? "1 tag") - : `${selectedCount} tags`} + ? (tags.find((t) => t.id === selectedIds[0])?.name ?? "1 keyword") + : `${selectedCount} keywords`} - + - + - + - No tags found. + No keywords found. {tags.map((tag) => { const checked = selectedIds.includes(tag.id); @@ -120,18 +106,13 @@ function TagsPicker({ onChange( checked ? removeTagId(selectedIds, tag.id) - : addTagId(selectedIds, tag.id), + : [...selectedIds, tag.id], ) } > - + # {tag.name} - {checked ? ( - - ) : null} + {checked ? : null} ); })} @@ -143,83 +124,37 @@ function TagsPicker({ ); } -function MoreFilters({ - filters, - options, - onFiltersChange, +function FilterChip({ + label, + value, + onRemove, }: { - filters: AssetFiltersState; - options: AssetFilterOptions; - onFiltersChange: (nextFilters: AssetFiltersState) => void; + label: string; + value: string; + onRemove: () => void; }) { - const defaultOpen = filters.locationId !== null || filters.tagIds.length > 0; - const [open, setOpen] = useState(defaultOpen); - - const locationOptions = useMemo( - () => options.locations as LocationPickerOption[], - [options.locations], - ); - return ( - - - - - More filters - - - - - -
      -
      - - - onFiltersChange({ ...filters, locationId }) - } - /> -
      - -
      - - onFiltersChange({ ...filters, tagIds })} - /> -
      -
      -
      -
      + + {label} + {value} + + ); } +/* + The Assets toolbar: the search field fills the row, then Category, Location, + Keywords and Service plan as quiet dropdowns of one width. On tablet the + dropdowns collapse to one Filters button. Applied filters show as chips in + the keyword family; only "Reset" has a button's silhouette. +*/ export function AssetFilters({ options, search, @@ -235,185 +170,173 @@ export function AssetFilters({ onFiltersChange: (nextFilters: AssetFiltersState) => void; onReset: () => void; }) { + const [moreOpen, setMoreOpen] = useState(false); + const locationOptions = useMemo( + () => options.locations as LocationPickerOption[], + [options.locations], + ); + // Status has its own tabs above the toolbar, so it never shows as a chip. const hasFilters = Boolean(search.trim()) || filters.categoryId !== null || - filters.status !== null || filters.locationId !== null || - filters.tagIds.length > 0; + filters.tagIds.length > 0 || + Boolean(filters.serviceGroupId); + + const dropdowns = ( + <> + + +
      + + Location + + onFiltersChange({ ...filters, locationId })} + compact + /> +
      + + + Keywords + + onFiltersChange({ ...filters, tagIds })} + /> + + {options.serviceGroups.length > 0 ? ( + + ) : null} + + ); return ( -
      -
      -
      - +
      +
      +
      + onSearchChange(event.target.value)} - placeholder="Search assets by name, tag, or notes" + placeholder="Search by name, tag or serial" className="pl-9" aria-label="Search assets" />
      - -
      - - - + + {/* One set of dropdowns: inline on desktop, a wrapped second row on + tablet when "More filters" is open. */} +
      + {dropdowns}
      - - -
      + {hasFilters ? (
      {filters.categoryId ? ( - - Category:{" "} - {options.categories.find((c) => c.id === filters.categoryId) - ?.name ?? "Unknown"} - - - ) : null} - {filters.status ? ( - - Status: {ASSET_STATUS_LABELS[filters.status]} - - + c.id === filters.categoryId)?.name ?? "Unknown"} + onRemove={() => onFiltersChange({ ...filters, categoryId: null })} + /> ) : null} {filters.locationId ? ( - - Location:{" "} - {options.locations.find((l) => l.id === filters.locationId) - ?.path ?? "Unknown"} - - + l.id === filters.locationId)?.name ?? "Unknown"} + onRemove={() => onFiltersChange({ ...filters, locationId: null })} + /> ) : null} - {filters.tagIds.map((tagId) => { - const tag = options.tags.find((t) => t.id === tagId); - return ( - - Tag: {tag?.name ?? "Unknown"} - - - ); - })} - {search.trim() ? ( - - Search: {search.trim()} - - + {filters.tagIds.map((tagId) => ( + t.id === tagId)?.name ?? "Unknown"} + onRemove={() => + onFiltersChange({ ...filters, tagIds: removeTagId(filters.tagIds, tagId) }) + } + /> + ))} + {filters.serviceGroupId ? ( + g.id === filters.serviceGroupId)?.name ?? "Unknown"} + onRemove={() => onFiltersChange({ ...filters, serviceGroupId: null })} + /> ) : null} - {!hasFilters ? ( - - No active filters - + {search.trim() ? ( + onSearchChange("")} /> ) : null} +
      - - -
      + ) : null}
      ); } diff --git a/src/components/assets/asset-form.tsx b/src/components/assets/asset-form.tsx index b37858a..3a95f5e 100644 --- a/src/components/assets/asset-form.tsx +++ b/src/components/assets/asset-form.tsx @@ -1,7 +1,8 @@ "use client"; import { useMemo, useState } from "react"; -import { Loader2 } from "lucide-react"; +import Link from "next/link"; +import { Loader2, TriangleAlert } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { DynamicField } from "@/components/fields/dynamic-field"; import type { FieldDefinition, FieldValue } from "@/components/fields/types"; @@ -27,8 +28,11 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { SheetHeading } from "@/components/ui/sheet-heading"; +import { TagChip } from "@/components/ui/tag-chip"; import { Textarea } from "@/components/ui/textarea"; import { previewAssetTag } from "@/lib/api/assets"; +import { cn } from "@/lib/utils"; type CategoryOption = { id: string; @@ -65,6 +69,7 @@ export function AssetForm({ assetTag, submitting, submitLabel, + cancelHref, serviceSchedulingEnabled, serviceScheduleDraft, onServiceScheduleChange, @@ -81,6 +86,8 @@ export function AssetForm({ assetTag?: string | null; submitting: boolean; submitLabel: string; + /* Where Cancel goes; omit to hide it. */ + cancelHref?: string; serviceSchedulingEnabled?: boolean; serviceScheduleDraft?: ServiceScheduleDraft; onServiceScheduleChange?: (next: ServiceScheduleDraft) => void; @@ -211,175 +218,208 @@ export function AssetForm({ }); } - return ( -
      -
      -
      - - setFieldValue("name", event.target.value)} - placeholder="Asset name" - disabled={submitting} - aria-invalid={Boolean(errors.name)} - /> - {errors.name ? ( -

      {errors.name}

      - ) : null} -
      - -
      - - -
      + const sectionGrid = "grid gap-x-6 gap-y-4 px-0.5 pt-4 sm:grid-cols-2"; -
      - -
      - {showTagLoading ? ( - + return ( + +
      + Details} /> +
      +
      + + setFieldValue("name", event.target.value)} + placeholder="Festool TS 55 plunge saw" + disabled={submitting} + aria-invalid={Boolean(errors.name)} + aria-describedby={errors.name ? "asset-name-error" : undefined} + /> + {errors.name ? ( + {errors.name} ) : null} - {activeAssetTag}
      -
      -
      - - -
      -
      +
      + Category + +
      -
      -
      - - setFieldValue("locationId", locationId)} - disabled={submitting} - /> -
      +
      + + Tag + +
      + {showTagLoading ? ( + + ) : ( + {activeAssetTag} + )} + + {mode === "create" + ? "Assigned from the category prefix when you save." + : "Printed on the label. It does not change."} + +
      +
      -
      - - -
      +
      + + setFieldValue("locationId", locationId)} + disabled={submitting} + /> +
      -
      - - setFieldValue("tagIds", tagIds)} - disabled={submitting} - /> -
      -
      +
      + + +
      -
      - -