From 5fe3cfbbfd7f7e972b60a758439438149a71704f Mon Sep 17 00:00:00 2001 From: Sauer Date: Sat, 7 Nov 2026 10:28:53 +0100 Subject: [PATCH 1/2] Rebuild the UI in the Workshop design Replace the old shadcn look with the Workshop spec: concrete background, charcoal sidebar, one amber primary, ink for selection, 2px rules under titles and sheet headings, cards only for objects and sheets for lists. - Tokens, globals, Switch and Badge restyled; new Panel, SheetHeading, StatStrip, TagChip, DueTag, StatusText and Eyebrow primitives - Login and app shell rebuilt (sidebar, tablet rail, phone top bar and bottom nav, user menu) - Dashboard, assets list and detail, locations, services (schedule, calendar, plans, providers), labels, settings, catalog, tags, asset forms and scan redone against the spec - Asset detail gains next-service, label and photo panels plus inline status and location pickers - Locations page reworked into a tree with rolled-up counts and a detail panel; the old location-tree component is removed - "Service group" copy renamed to "service plan" - Hydration-safe client-only values via useSyncExternalStore - Tests updated for the new markup Co-Authored-By: Claude Opus 5.5 --- eslint.config.mjs | 2 + next.config.ts | 4 + .../assets/asset-card-list.test.tsx | 186 ++- .../assets/asset-create-page-client.test.tsx | 1 + .../components/assets/asset-detail.test.tsx | 439 ++++-- .../assets/asset-filters-mobile.test.tsx | 65 +- .../components/assets/asset-filters.test.tsx | 104 +- .../assets/assets-page-client.test.tsx | 231 +++- .../catalog/taxonomy-manager.test.tsx | 18 +- .../dashboard/category-breakdown.test.tsx | 19 +- .../dashboard/dashboard-stats-bar.test.tsx | 125 +- .../dashboard/location-breakdown.test.tsx | 51 - .../dashboard/quick-actions.test.tsx | 37 - .../dashboard/recent-assets-card.test.tsx | 119 +- .../components/dashboard/stat-card.test.tsx | 36 - .../upcoming-services-widget.test.tsx | 59 +- .../components/layout/app-shell.test.tsx | 25 +- .../components/layout/app-sidebar.test.tsx | 181 ++- .../components/layout/bottom-nav.test.tsx | 80 +- .../components/layout/topbar.test.tsx | 81 -- .../components/layout/user-menu.test.tsx | 106 ++ .../locations/location-tree.test.tsx | 102 -- .../services/next-service-panel.test.tsx | 182 +++ .../service-group-assets-panel.test.tsx | 16 +- .../service-group-detail-page-client.test.tsx | 6 +- .../services/service-group-editor.test.tsx | 14 +- .../service-group-fields-panel.test.tsx | 2 +- .../services/service-groups-list.test.tsx | 4 +- .../services/service-history.test.tsx | 149 +- .../service-providers-page-client.test.tsx | 8 +- .../services-calendar-page-client.test.tsx | 3 +- .../services-list-page-client.test.tsx | 4 +- .../services/services-nav-tabs.test.tsx | 14 +- .../services/services-scheduled-list.test.tsx | 12 +- .../components/tags/tag-picker.test.tsx | 22 +- .../components/tags/tags-page-client.test.tsx | 4 +- src/app/(app)/assets/[id]/edit/page.tsx | 6 +- src/app/(app)/assets/[id]/page.tsx | 17 +- src/app/(app)/assets/new/page.tsx | 14 +- src/app/(app)/assets/page.tsx | 13 +- src/app/(app)/dashboard/page.tsx | 206 ++- src/app/(app)/labels/page.tsx | 20 +- src/app/(app)/locations/page.tsx | 10 +- src/app/(app)/services/calendar/page.tsx | 14 +- src/app/(app)/services/groups/page.tsx | 14 +- src/app/(app)/services/layout.tsx | 10 + src/app/(app)/services/page.tsx | 13 +- src/app/(app)/services/providers/page.tsx | 14 +- src/app/(app)/settings/page.tsx | 5 +- src/app/(app)/taxonomy/page.tsx | 18 +- src/app/(auth)/layout.tsx | 8 +- src/app/(print)/labels/print/page.tsx | 7 +- src/app/globals.css | 257 ++-- src/app/layout.tsx | 24 +- src/components/assets/asset-card-list.tsx | 164 +-- .../assets/asset-create-page-client.tsx | 119 +- .../assets/asset-detail-page-client.tsx | 56 +- src/components/assets/asset-detail.tsx | 566 +++++--- .../assets/asset-edit-page-client.tsx | 97 +- .../assets/asset-filters-mobile.tsx | 135 +- src/components/assets/asset-filters.tsx | 467 +++---- src/components/assets/asset-form.tsx | 399 +++--- src/components/assets/asset-photo-slot.tsx | 94 ++ src/components/assets/asset-table.tsx | 376 +++-- src/components/assets/assets-page-client.tsx | 530 +++++--- .../assets/service-schedule-fields.tsx | 88 +- src/components/assets/status-badge.tsx | 39 +- .../attachments/attachment-card.tsx | 137 +- .../attachments/attachment-list.tsx | 109 +- .../attachments/file-upload-zone.tsx | 76 +- src/components/auth/auth-panel.tsx | 16 +- src/components/auth/auth-shell.tsx | 90 ++ src/components/auth/login-form.tsx | 109 +- src/components/auth/setup-form.tsx | 32 +- src/components/brand/stowage-mark.tsx | 61 + src/components/catalog/taxonomy-manager.tsx | 390 +++--- src/components/crud/confirm-dialog.tsx | 22 +- src/components/crud/modal.tsx | 37 +- .../dashboard/category-breakdown.tsx | 65 +- .../dashboard/dashboard-stats-bar.tsx | 128 +- .../dashboard/location-breakdown.tsx | 43 - src/components/dashboard/quick-actions.tsx | 24 - .../dashboard/recent-assets-card.tsx | 101 +- src/components/dashboard/stat-card.tsx | 37 - .../dashboard/upcoming-services-widget.tsx | 142 +- src/components/fields/dynamic-field.tsx | 88 +- .../fields/field-definition-form.tsx | 65 +- src/components/fields/fields-page-client.tsx | 387 +++--- src/components/labels/asset-label-panel.tsx | 145 ++ src/components/labels/element-properties.tsx | 863 ++++++------ src/components/labels/element-toolbar.tsx | 48 +- .../labels/label-print-page-client.tsx | 222 +-- src/components/labels/label-print.tsx | 52 +- src/components/labels/labels-page-client.tsx | 100 +- src/components/labels/printed-label.tsx | 74 + src/components/labels/template-canvas.tsx | 56 +- src/components/labels/template-designer.tsx | 221 +-- src/components/labels/use-barcode-svg.ts | 37 + src/components/layout/app-shell.tsx | 26 +- src/components/layout/app-sidebar.tsx | 323 +++-- src/components/layout/bottom-nav.tsx | 153 ++- src/components/layout/mobile-action-sheet.tsx | 15 +- src/components/layout/mobile-top-bar.tsx | 106 ++ src/components/layout/nav-config.ts | 79 ++ src/components/layout/page-header.tsx | 95 +- src/components/layout/topbar.tsx | 118 -- src/components/layout/user-menu.tsx | 153 +++ .../locations/location-form-dialog.tsx | 115 +- src/components/locations/location-path.tsx | 51 + src/components/locations/location-picker.tsx | 38 +- .../locations/location-tree.module.css | 87 -- src/components/locations/location-tree.tsx | 282 ---- src/components/locations/location-utils.ts | 47 + .../locations/locations-page-client.tsx | 1206 ++++++++++------- src/components/scan/scan-page-client.tsx | 63 +- src/components/scan/scan-viewport.tsx | 2 +- src/components/search/global-search.tsx | 96 +- .../services/next-service-panel.tsx | 162 +++ .../services/scheduling-disabled.tsx | 20 + .../services/service-group-assets-panel.tsx | 119 +- .../service-group-detail-page-client.tsx | 71 +- .../services/service-group-editor.tsx | 13 +- .../services/service-group-fields-panel.tsx | 80 +- .../services/service-groups-list.tsx | 352 +++-- src/components/services/service-history.tsx | 310 ++--- .../service-providers-page-client.tsx | 188 +-- .../services/service-record-attachments.tsx | 6 +- .../services/service-record-form.tsx | 22 +- .../services/services-calendar-month.tsx | 187 +-- .../services-calendar-page-client.tsx | 17 +- src/components/services/services-header.tsx | 65 + .../services/services-list-page-client.tsx | 17 +- src/components/services/services-nav-tabs.tsx | 16 +- .../services/services-scheduled-list.tsx | 340 ++--- src/components/settings/features-section.tsx | 42 +- .../settings/password-change-section.tsx | 64 +- .../settings/regional-settings-section.tsx | 48 +- .../settings/settings-page-client.tsx | 35 +- .../settings/user-management-section.tsx | 177 ++- src/components/tags/tag-picker.tsx | 99 +- src/components/ui/badge.tsx | 3 +- src/components/ui/button.tsx | 35 +- src/components/ui/checkbox.tsx | 5 +- src/components/ui/dialog.tsx | 22 +- src/components/ui/due-tag.tsx | 56 + src/components/ui/empty-state.tsx | 20 +- src/components/ui/eyebrow.tsx | 11 + src/components/ui/input.tsx | 6 +- src/components/ui/panel.tsx | 51 + src/components/ui/select.tsx | 8 +- src/components/ui/sheet-heading.tsx | 64 + src/components/ui/sheet.tsx | 4 +- src/components/ui/skeleton.tsx | 2 +- src/components/ui/sonner.tsx | 38 +- src/components/ui/stat-strip.tsx | 108 ++ src/components/ui/status-text.tsx | 59 + src/components/ui/switch.tsx | 5 +- src/components/ui/tabs.tsx | 16 +- src/components/ui/tag-chip.tsx | 102 ++ src/hooks/use-barcode-scanner.ts | 9 + src/hooks/use-hydrated.ts | 18 + src/hooks/use-long-press.ts | 64 + src/hooks/use-overdue-count.ts | 19 + src/lib/api/assets.ts | 2 +- src/lib/api/dashboard.ts | 1 + src/lib/relative-time.ts | 49 + src/server/domain/assets.ts | 5 +- src/server/domain/dashboard.ts | 8 +- 168 files changed, 9689 insertions(+), 6894 deletions(-) delete mode 100644 src/__tests__/components/dashboard/location-breakdown.test.tsx delete mode 100644 src/__tests__/components/dashboard/quick-actions.test.tsx delete mode 100644 src/__tests__/components/dashboard/stat-card.test.tsx delete mode 100644 src/__tests__/components/layout/topbar.test.tsx create mode 100644 src/__tests__/components/layout/user-menu.test.tsx delete mode 100644 src/__tests__/components/locations/location-tree.test.tsx create mode 100644 src/__tests__/components/services/next-service-panel.test.tsx create mode 100644 src/app/(app)/services/layout.tsx create mode 100644 src/components/assets/asset-photo-slot.tsx create mode 100644 src/components/auth/auth-shell.tsx create mode 100644 src/components/brand/stowage-mark.tsx delete mode 100644 src/components/dashboard/location-breakdown.tsx delete mode 100644 src/components/dashboard/quick-actions.tsx delete mode 100644 src/components/dashboard/stat-card.tsx create mode 100644 src/components/labels/asset-label-panel.tsx create mode 100644 src/components/labels/printed-label.tsx create mode 100644 src/components/labels/use-barcode-svg.ts create mode 100644 src/components/layout/mobile-top-bar.tsx create mode 100644 src/components/layout/nav-config.ts delete mode 100644 src/components/layout/topbar.tsx create mode 100644 src/components/layout/user-menu.tsx create mode 100644 src/components/locations/location-path.tsx delete mode 100644 src/components/locations/location-tree.module.css delete mode 100644 src/components/locations/location-tree.tsx create mode 100644 src/components/locations/location-utils.ts create mode 100644 src/components/services/next-service-panel.tsx create mode 100644 src/components/services/scheduling-disabled.tsx create mode 100644 src/components/services/services-header.tsx create mode 100644 src/components/ui/due-tag.tsx create mode 100644 src/components/ui/eyebrow.tsx create mode 100644 src/components/ui/panel.tsx create mode 100644 src/components/ui/sheet-heading.tsx create mode 100644 src/components/ui/stat-strip.tsx create mode 100644 src/components/ui/status-text.tsx create mode 100644 src/components/ui/tag-chip.tsx create mode 100644 src/hooks/use-hydrated.ts create mode 100644 src/hooks/use-long-press.ts create mode 100644 src/hooks/use-overdue-count.ts create mode 100644 src/lib/relative-time.ts 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..d8a6875 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,125 @@ 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(); + + 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("shows an overflow count when more than three tags", () => { + 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("has no kebab button; press and hold opens the actions sheet instead", () => { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + vi.setSystemTime(NOW); + try { + renderList([makeRow()]); + expect(screen.queryByRole("button")).toBeNull(); + + 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()]); - 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"); + 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/assets-page-client.test.tsx b/src/__tests__/components/assets/assets-page-client.test.tsx index 6fa6a29..9af7bdc 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,99 @@ 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: [], + 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 +129,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 +170,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/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/layout/app-shell.test.tsx b/src/__tests__/components/layout/app-shell.test.tsx index 6c76dc1..d5c0560 100644 --- a/src/__tests__/components/layout/app-shell.test.tsx +++ b/src/__tests__/components/layout/app-shell.test.tsx @@ -27,6 +27,10 @@ vi.mock("@/lib/api/auth", () => ({ logout: vi.fn(), })); +vi.mock("@/hooks/use-overdue-count", () => ({ + useOverdueCount: () => 0, +})); + vi.mock("@/components/search/global-search", () => ({ GlobalSearch: () => ( + ), +})); + +vi.mock("@/components/layout/user-menu", () => ({ + UserMenu: () => ( + + ), +})); -function renderWithProviders() { - return render( - - - , - ); -} +import { AppRail, AppSidebar } from "@/components/layout/app-sidebar"; + +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 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 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 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("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..120a581 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("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 on large viewports", () => { + 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,23 @@ describe("BottomNav", () => { ); }); + it("shows the overdue count on Services", () => { + overdueSpy.mockReturnValue(2); + renderNav(); + expect(screen.getByTestId("bottom-nav-services")).toHaveTextContent( + "Services2", + ); + }); + + 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 +192,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/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/services/next-service-panel.test.tsx b/src/__tests__/components/services/next-service-panel.test.tsx new file mode 100644 index 0000000..ef4eb9c --- /dev/null +++ b/src/__tests__/components/services/next-service-panel.test.tsx @@ -0,0 +1,182 @@ +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("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..95c9d1f 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,81 @@ 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(); + 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-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/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/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..97264d3 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -1,26 +1,34 @@ "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 { 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)"); const { data: currentUser } = useCurrentUser(); const { data: overview, @@ -31,134 +39,92 @@ export default function DashboardPage() { queryFn: getDashboardOverview, }); - const greeting = currentUser?.name - ? `Welcome back, ${currentUser.name}` - : "Dashboard"; + const now = new Date(); + const firstName = currentUser?.name?.split(/\s+/)[0]; + const actions = ( + + ); if (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.recentAssets.filter( + (asset) => now.getTime() - asset.updatedAt < 24 * 60 * 60 * 1000, + ).length; + 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/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..f323f32 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,99 +48,188 @@ --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; + } +} + +/* 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 { 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-card-list.tsx b/src/components/assets/asset-card-list.tsx index bdecc01..a1a3bef 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 { 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,20 @@ 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. Press and hold a card for quick actions. +*/ 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 +78,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 +107,21 @@ export function AssetCardList({ return ( ); } return ( <> -
      +
        {rows.map((row) => ( -
      • - setActiveId(row.id)} /> +
      • + setActiveId(row.id)} />
      • ))}
      @@ -120,7 +134,7 @@ export function AssetCardList({ onAssetUpdated={() => { // list invalidates via the nested mutations' query invalidation }} - dismissLabel="Close ↓" + dismissLabel="Close" testIdPrefix="asset" /> @@ -129,84 +143,48 @@ 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 ( -
      { + event.preventDefault(); + if (consumeClick()) return; + router.push(`/assets/${row.id}`); + }} > -
      -
      -
      - {row.categoryColor ? ( -
      -

      - {row.assetTag} -

      - {row.locationPath ? ( -

      -

      - ) : null} - {visibleTags.length > 0 ? ( -
      - {visibleTags.map((name) => ( - - {name} - - ))} - {extraTagCount > 0 ? ( - - +{extraTagCount} - - ) : null} -
      - ) : null} -
      -
      - - -
      -
      -
      + {row.assetTag} + + {row.name} + + + {secondary === "category" ? ( + {row.categoryName ?? "No category"} + ) : ( + + )} + + {" · "} + {time.short} + + + {row.status !== "active" ? ( + + ) : 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..413862b 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,22 @@ 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"] }); + }, + onError: (error) => { + toast.error(getAssetUiErrorMessage(error, "Unable to move asset")); + }, + }); + const deleteMutation = useMutation({ mutationFn: () => deleteAsset(assetId), onSuccess: () => { @@ -82,22 +104,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 +141,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..9ff22f9 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,71 +130,163 @@ 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} - /> -
      -
      +
      + + +
      -
      - -