From c93c20a02a999162a48af1ee714b85370e629d23 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Sun, 25 Oct 2026 15:15:33 +0530 Subject: [PATCH 01/11] feat(shell): bell icon in place of the Inbox word beside the space name Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- components/chrome/AppShell.tsx | 6 +++--- tests/unit/design-fidelity.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/chrome/AppShell.tsx b/components/chrome/AppShell.tsx index 8b2fec1..9af06a8 100644 --- a/components/chrome/AppShell.tsx +++ b/components/chrome/AppShell.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { signOut } from "next-auth/react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; -import { ChevronsUpDown, LogOut, Moon, Sun } from "lucide-react"; +import { Bell, ChevronsUpDown, LogOut, Moon, Sun } from "lucide-react"; import { cn } from "@/lib/cn"; import type { ShellData } from "@/lib/appShell"; import { activeNavKey } from "@/lib/nav"; @@ -119,8 +119,8 @@ function NotificationsLink({ data, onNavigate }: { data: ShellData; onNavigate?: aria-label={label} className="inline-flex h-11 min-w-11 shrink-0 items-center justify-center gap-1.5 rounded-md px-2 text-ink transition-colors hover:bg-sidebar-hover" > - Inbox - {/* Inline, not overlaid: an absolute badge covered the word. */} + + {/* Inline, not overlaid: an absolute badge covered the bell. */} {count > 0 ? ( {count > 99 ? "99+" : count} diff --git a/tests/unit/design-fidelity.test.ts b/tests/unit/design-fidelity.test.ts index 1d72974..140fa94 100644 --- a/tests/unit/design-fidelity.test.ts +++ b/tests/unit/design-fidelity.test.ts @@ -180,7 +180,7 @@ describe("design source guards across every area", () => { for (const file of files.filter(f => f.endsWith(".tsx"))) { const source = ts.createSourceFile(file, read(file), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); const icons = new Set(); - const approved = new Set(["Archive", "Cpu", "MapPin", "Search", "Plus", "ArrowLeft", "ArrowUpRight", "ChevronsUpDown", "Info", "TriangleAlert", "CircleCheck", "Ellipsis", "Sun", "Moon", "LogOut", "Package", "Boxes", "QrCode", "ScanLine", "Camera", "Warehouse", "ArrowLeftRight", "Truck", "Wrench", "ClipboardCheck", "Tag", "Printer", "Upload", "Download", "CreditCard", "Undo2"]); + const approved = new Set(["Archive", "Cpu", "MapPin", "Search", "Plus", "ArrowLeft", "ArrowUpRight", "ChevronsUpDown", "Info", "TriangleAlert", "CircleCheck", "Ellipsis", "Sun", "Moon", "LogOut", "Package", "Boxes", "QrCode", "ScanLine", "Camera", "Warehouse", "ArrowLeftRight", "Truck", "Wrench", "ClipboardCheck", "Tag", "Printer", "Upload", "Download", "CreditCard", "Undo2", "Bell"]); for (const statement of source.statements) if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "lucide-react") { const bindings = statement.importClause?.namedBindings; if (bindings && ts.isNamedImports(bindings)) for (const icon of bindings.elements) { From 42e80b22100866e4219c6e82cc5e434a637961e6 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Sun, 25 Oct 2026 15:24:50 +0530 Subject: [PATCH 02/11] feat(home): collapsible dashboard sections; regenerate the frontend catalog for the bell Desk queues and member loan lists fold behind their title with a count, start folded when empty and remember this browser's choice. Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ components/home/MemberHome.tsx | 11 ++-- components/home/StaffHome.tsx | 25 ++++----- components/ui/collapsible.tsx | 82 +++++++++++++++++++++++++++++ docs/DECISIONS.md | 8 +++ frontend/contracts.d.ts | 2 +- tests/unit/collapsible-card.test.ts | 39 ++++++++++++++ tests/unit/design-fidelity.test.ts | 2 +- 8 files changed, 153 insertions(+), 20 deletions(-) create mode 100644 components/ui/collapsible.tsx create mode 100644 tests/unit/collapsible-card.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e3cd3e4..6fc7f9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,10 @@ The first public release: everything since the split from SpaceWorks ### Changed +- The home page's desk queues and loan lists collapse; empty ones start + collapsed, show their count and remember this browser's choice. +- The notifications link beside the space name is a bell icon instead of + the word "Inbox". - **Roles: Inventory manager, Staff and Member, plus Technician** (repairs only, with an authenticator app). The admin and owner roles are gone; the inventory manager runs the whole space, including members, other managers and deleting the space. The space's creator is its diff --git a/components/home/MemberHome.tsx b/components/home/MemberHome.tsx index 5507a8e..421e857 100644 --- a/components/home/MemberHome.tsx +++ b/components/home/MemberHome.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { BorrowingChip, LoanStatusChip } from "@/components/inventory/StatusChips"; import { buttonClasses } from "@/components/ui/button"; +import { CollapsibleCard } from "@/components/ui/collapsible"; import { Callout, Card, CardHeader, EmptyState } from "@/components/ui/display"; import { loanRef } from "@/lib/catalogue/references"; import { formatRelativeDay } from "@/lib/format/datetime"; @@ -44,16 +45,14 @@ export function MemberHome({ slug, timezone, lendingEnabled, data }: { slug: str ) : ( <> {data.active.length > 0 ? ( - - + - + ) : null} {data.pending.length > 0 ? ( - - + - + ) : null} )} diff --git a/components/home/StaffHome.tsx b/components/home/StaffHome.tsx index 08fb4ad..53942cf 100644 --- a/components/home/StaffHome.tsx +++ b/components/home/StaffHome.tsx @@ -1,7 +1,8 @@ import Link from "next/link"; import type { MovementType } from "@prisma/client"; import { CountStatusChip, TransferStatusChip } from "@/components/inventory/StatusChips"; -import { Card, CardHeader, StatTile } from "@/components/ui/display"; +import { CollapsibleCard } from "@/components/ui/collapsible"; +import { StatTile } from "@/components/ui/display"; import { countRef, loanRef, transferRef } from "@/lib/catalogue/references"; import { formatRelativeTime } from "@/lib/format/datetime"; import { formatQuantity } from "@/lib/format/quantity"; @@ -15,12 +16,12 @@ const MOVEMENT_WORD: Record = { const linkClass = "font-semibold text-accent-text hover:underline underline-offset-4"; -function Queue({ title, empty, href, children }: { title: string; empty: string; href: string; children: React.ReactNode[] }) { +/** A desk queue that folds away; empty queues start folded so the busy ones stand out. */ +function Queue({ id, title, empty, href, children }: { id: string; title: string; empty: string; href: string; children: React.ReactNode[] }) { return ( - - View all} className="mb-3" /> + 0} action={View all}> {children.length === 0 ?

{empty}

:
    {children}
} -
+ ); } @@ -47,23 +48,23 @@ export function StaffHome({ slug, timezone, data }: { slug: string; timezone: st 0 ? "attention" : "default"} href={`${base}/loans?tab=overdue`} /> 0 ? "attention" : "default"} href={`${base}/purchases`} /> -
- +
+ {data.requests.map((loan) => )} - + {data.ready.map((loan) => )} - + {data.transfersOut.map((t) => } />)} - + {data.transfersIn.map((t) => } />)} - + {data.counts.map((c) => } />)} - + {data.movements.map((m) => (
  • diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx new file mode 100644 index 0000000..9624861 --- /dev/null +++ b/components/ui/collapsible.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useId, useState, useSyncExternalStore, type ReactNode } from "react"; +import { ChevronDown } from "lucide-react"; +import { cn } from "@/lib/cn"; + +const STORAGE_PREFIX = "swi:dash:"; + +function readSaved(id: string): boolean | null { + try { + const saved = window.localStorage.getItem(STORAGE_PREFIX + id); + return saved === "open" ? true : saved === "closed" ? false : null; + } catch { + return null; + } +} + +function subscribe(onChange: () => void) { + window.addEventListener("storage", onChange); + return () => window.removeEventListener("storage", onChange); +} + +function save(id: string, open: boolean) { + try { + window.localStorage.setItem(STORAGE_PREFIX + id, open ? "open" : "closed"); + } catch { + // Private windows and blocked storage just forget the choice. + } +} + +/** + * A card whose body folds away behind its title. Starts from `defaultOpen` + * (empty sections start closed) and then remembers this browser's choice. + */ +export function CollapsibleCard({ + id, title, count, action, defaultOpen = true, titleAs: Title = "h2", children, +}: { + id: string; + title: string; + count?: number; + action?: ReactNode; + defaultOpen?: boolean; + titleAs?: "h2" | "h3"; + children: ReactNode; +}) { + // The server and the first client render use defaultOpen; the saved choice applies after hydration. + const saved = useSyncExternalStore(subscribe, () => readSaved(id), () => null); + const [choice, setChoice] = useState(null); + const open = choice ?? saved ?? defaultOpen; + const bodyId = useId(); + + function toggle() { + save(id, !open); + setChoice(!open); + } + + return ( +
    +
    + + <button + type="button" + onClick={toggle} + aria-expanded={open} + aria-controls={bodyId} + className="flex min-h-14 w-full items-center gap-2 rounded-xl px-5 text-left text-title text-ink transition-colors hover:bg-surface" + > + <ChevronDown aria-hidden className={cn("size-4 shrink-0 text-ink-muted transition-transform duration-[160ms] motion-reduce:transition-none", !open && "-rotate-90")} /> + <span className="min-w-0 truncate">{title}</span> + {count !== undefined ? ( + <span className="min-w-6 rounded-full bg-surface px-2 text-center text-caption font-semibold leading-6 tabular-nums text-ink-secondary">{count}</span> + ) : null} + </button> + + {action ?
    {action}
    : null} +
    + +
    + ); +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 121bc78..ec57db1 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -107,6 +107,14 @@ only from [SpaceWorks-Design](https://spaceworks-hq.github.io/SpaceWorks-Design/ they asked and whether it is paid. 10. **No pauses.** The build continues without waiting for owner decisions; commits stay local on `dev` until the owner's QA. +11. **Dashboard sections collapse** (2026-10-25). The home page was too busy: + each desk queue and the member's loan lists fold behind their title, show + their count, start folded when empty and remember this browser's choice + (`localStorage`, `swi:dash:`). The stat tiles stay visible. +12. **Bell for notifications** (2026-10-25). The notifications link beside + the space name is a bell icon in the same ink colour (it was the word + "Inbox"); the unread count still sits beside it and the link keeps its + "Notifications, N unread" name. ## Defaults the owner may revisit diff --git a/frontend/contracts.d.ts b/frontend/contracts.d.ts index 127c72d..243c612 100644 --- a/frontend/contracts.d.ts +++ b/frontend/contracts.d.ts @@ -1,5 +1,5 @@ // Generated transport DTOs. No backend imports. -// Source SHA256: ba1ed013401d7c9e0dddb7def46c3ab1e3e582e1dbb45d5e802a14f08e4b8806 +// Source SHA256: 8d39df684cabdd8d0ffa013531ad1e1da4d3b0b03fff932d69f6826bf5902714 export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; export type Views = { "account-page:loadAccountPage": { "user": { "email": string; "name": null | string; "phone": null | string; "mfaEnabledAt": null | Date }; "spaces": Array<{ "id": string; "slug": string; "name": string; "role": "MANAGER" | "STAFF" | "MEMBER" | "TECHNICIAN" }>; "remaining": number; "hasPassword": boolean }; diff --git a/tests/unit/collapsible-card.test.ts b/tests/unit/collapsible-card.test.ts new file mode 100644 index 0000000..714a4e3 --- /dev/null +++ b/tests/unit/collapsible-card.test.ts @@ -0,0 +1,39 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { CollapsibleCard } from "@/components/ui/collapsible"; + +type Props = Omit[0], "children">; + +function render(props: Partial = {}) { + const all: Props = { id: "queue", title: "Loan requests", ...props }; + return renderToStaticMarkup(createElement(CollapsibleCard as (p: Props) => ReturnType, all, createElement("p", null, "Body"))); +} + +describe("CollapsibleCard", () => { + it("renders open by default with the title in a named toggle", () => { + const html = render(); + expect(html).toMatch(/]*aria-expanded="true"/); + expect(html).toContain("Loan requests"); + expect(html).not.toMatch(/]*hidden=""/); + }); + + it("starts folded when defaultOpen is false and hides the body", () => { + const html = render({ defaultOpen: false }); + expect(html).toMatch(/]*aria-expanded="false"/); + expect(html).toMatch(/]*hidden=""[^>]*>.*Body/); + }); + + it("points the toggle at the body it controls", () => { + const html = render(); + const controls = html.match(/aria-controls="([^"]+)"/)?.[1]; + expect(controls).toBeTruthy(); + expect(html).toContain(`id="${controls}"`); + }); + + it("shows the count, including zero, and keeps the action outside the toggle", () => { + const html = render({ count: 0, action: createElement("a", { href: "/all" }, "View all") }); + expect(html).toMatch(/tabular-nums[^>]*>0")).toBeLessThan(html.indexOf("View all")); + }); +}); diff --git a/tests/unit/design-fidelity.test.ts b/tests/unit/design-fidelity.test.ts index 140fa94..49fff49 100644 --- a/tests/unit/design-fidelity.test.ts +++ b/tests/unit/design-fidelity.test.ts @@ -180,7 +180,7 @@ describe("design source guards across every area", () => { for (const file of files.filter(f => f.endsWith(".tsx"))) { const source = ts.createSourceFile(file, read(file), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); const icons = new Set(); - const approved = new Set(["Archive", "Cpu", "MapPin", "Search", "Plus", "ArrowLeft", "ArrowUpRight", "ChevronsUpDown", "Info", "TriangleAlert", "CircleCheck", "Ellipsis", "Sun", "Moon", "LogOut", "Package", "Boxes", "QrCode", "ScanLine", "Camera", "Warehouse", "ArrowLeftRight", "Truck", "Wrench", "ClipboardCheck", "Tag", "Printer", "Upload", "Download", "CreditCard", "Undo2", "Bell"]); + const approved = new Set(["Archive", "Cpu", "MapPin", "Search", "Plus", "ArrowLeft", "ArrowUpRight", "ChevronsUpDown", "Info", "TriangleAlert", "CircleCheck", "Ellipsis", "Sun", "Moon", "LogOut", "Package", "Boxes", "QrCode", "ScanLine", "Camera", "Warehouse", "ArrowLeftRight", "Truck", "Wrench", "ClipboardCheck", "Tag", "Printer", "Upload", "Download", "CreditCard", "Undo2", "Bell", "ChevronDown"]); for (const statement of source.statements) if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "lucide-react") { const bindings = statement.importClause?.namedBindings; if (bindings && ts.isNamedImports(bindings)) for (const icon of bindings.elements) { From d7c81bd56efbd0186852261b1cc67602d493d906 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Sun, 25 Oct 2026 16:10:21 +0530 Subject: [PATCH 03/11] feat(ui): real icons for the menu, close and dismiss controls instead of words and glyphs Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- components/chrome/AppShell.tsx | 6 +++--- components/feedback/ToastProvider.tsx | 4 ++-- components/ui/dialog.tsx | 3 ++- components/ui/field.tsx | 4 ++-- frontend/contracts.d.ts | 2 +- tests/unit/design-fidelity.test.ts | 2 +- 6 files changed, 11 insertions(+), 10 deletions(-) diff --git a/components/chrome/AppShell.tsx b/components/chrome/AppShell.tsx index 9af06a8..5cf0e3d 100644 --- a/components/chrome/AppShell.tsx +++ b/components/chrome/AppShell.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { signOut } from "next-auth/react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; -import { Bell, ChevronsUpDown, LogOut, Moon, Sun } from "lucide-react"; +import { Bell, ChevronsUpDown, LogOut, Menu as MenuIcon, Moon, Sun, X } from "lucide-react"; import { cn } from "@/lib/cn"; import type { ShellData } from "@/lib/appShell"; import { activeNavKey } from "@/lib/nav"; @@ -191,7 +191,7 @@ export function AppShell({ data, children }: { data: ShellData; children: ReactN - Menu + @@ -205,7 +205,7 @@ export function AppShell({ data, children }: { data: ShellData; children: ReactN headerAction={ - Close + } diff --git a/components/feedback/ToastProvider.tsx b/components/feedback/ToastProvider.tsx index a3fcb92..f48b47d 100644 --- a/components/feedback/ToastProvider.tsx +++ b/components/feedback/ToastProvider.tsx @@ -1,7 +1,7 @@ "use client"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -import { TriangleAlert as AlertTriangle, CircleCheck as CheckCircle2, Info, TriangleAlert as XCircle } from "lucide-react"; +import { TriangleAlert as AlertTriangle, CircleCheck as CheckCircle2, Info, TriangleAlert as XCircle, X } from "lucide-react"; import { cn } from "@/lib/cn"; type Severity = "success" | "error" | "info" | "warning"; @@ -87,7 +87,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { ) : null}
  • ) : null} diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index c99ef41..9553ba7 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -2,6 +2,7 @@ import { forwardRef, useState, type ComponentPropsWithoutRef, type ElementRef, type ReactNode } from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; import { cn } from "@/lib/cn"; import { Button, IconButton } from "./button"; @@ -37,7 +38,7 @@ export const DialogContent = forwardRef< {hideClose ? null : ( - + )} diff --git a/components/ui/field.tsx b/components/ui/field.tsx index e682fee..dbfd6e7 100644 --- a/components/ui/field.tsx +++ b/components/ui/field.tsx @@ -11,7 +11,7 @@ import { type ReactNode, type TextareaHTMLAttributes, } from "react"; -import { TriangleAlert as AlertCircle, Search } from "lucide-react"; +import { TriangleAlert as AlertCircle, Search, X } from "lucide-react"; import { cn } from "@/lib/cn"; import { controlClasses } from "./styles"; @@ -144,7 +144,7 @@ export const SearchInput = forwardRef - + ) : null}
    diff --git a/frontend/contracts.d.ts b/frontend/contracts.d.ts index 243c612..d56c0aa 100644 --- a/frontend/contracts.d.ts +++ b/frontend/contracts.d.ts @@ -1,5 +1,5 @@ // Generated transport DTOs. No backend imports. -// Source SHA256: 8d39df684cabdd8d0ffa013531ad1e1da4d3b0b03fff932d69f6826bf5902714 +// Source SHA256: f61c1be39fb7f431fbec174d9a4aa49046a70f051f5942c5eb3b7e515bae1d5f export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; export type Views = { "account-page:loadAccountPage": { "user": { "email": string; "name": null | string; "phone": null | string; "mfaEnabledAt": null | Date }; "spaces": Array<{ "id": string; "slug": string; "name": string; "role": "MANAGER" | "STAFF" | "MEMBER" | "TECHNICIAN" }>; "remaining": number; "hasPassword": boolean }; diff --git a/tests/unit/design-fidelity.test.ts b/tests/unit/design-fidelity.test.ts index 49fff49..fbc72ae 100644 --- a/tests/unit/design-fidelity.test.ts +++ b/tests/unit/design-fidelity.test.ts @@ -180,7 +180,7 @@ describe("design source guards across every area", () => { for (const file of files.filter(f => f.endsWith(".tsx"))) { const source = ts.createSourceFile(file, read(file), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); const icons = new Set(); - const approved = new Set(["Archive", "Cpu", "MapPin", "Search", "Plus", "ArrowLeft", "ArrowUpRight", "ChevronsUpDown", "Info", "TriangleAlert", "CircleCheck", "Ellipsis", "Sun", "Moon", "LogOut", "Package", "Boxes", "QrCode", "ScanLine", "Camera", "Warehouse", "ArrowLeftRight", "Truck", "Wrench", "ClipboardCheck", "Tag", "Printer", "Upload", "Download", "CreditCard", "Undo2", "Bell", "ChevronDown"]); + const approved = new Set(["Archive", "Cpu", "MapPin", "Search", "Plus", "ArrowLeft", "ArrowUpRight", "ChevronsUpDown", "Info", "TriangleAlert", "CircleCheck", "Ellipsis", "Sun", "Moon", "LogOut", "Package", "Boxes", "QrCode", "ScanLine", "Camera", "Warehouse", "ArrowLeftRight", "Truck", "Wrench", "ClipboardCheck", "Tag", "Printer", "Upload", "Download", "CreditCard", "Undo2", "Bell", "ChevronDown", "Menu", "X"]); for (const statement of source.statements) if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "lucide-react") { const bindings = statement.importClause?.namedBindings; if (bindings && ts.isNamedImports(bindings)) for (const icon of bindings.elements) { From 0c5c067e9ea06c8f56ccbdbc40f48d0e51515035 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Sun, 25 Oct 2026 16:16:33 +0530 Subject: [PATCH 04/11] feat(shell): sidebar groups fold; the group holding the current page stays open Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- components/chrome/AppShell.tsx | 52 ++++++++++++++++++++++++++-------- components/ui/collapsible.tsx | 35 +++++++++++++---------- 2 files changed, 60 insertions(+), 27 deletions(-) diff --git a/components/chrome/AppShell.tsx b/components/chrome/AppShell.tsx index 5cf0e3d..b927502 100644 --- a/components/chrome/AppShell.tsx +++ b/components/chrome/AppShell.tsx @@ -1,11 +1,11 @@ "use client"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { signOut } from "next-auth/react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; -import { Bell, ChevronsUpDown, LogOut, Menu as MenuIcon, Moon, Sun, X } from "lucide-react"; +import { Bell, ChevronDown, ChevronsUpDown, LogOut, Menu as MenuIcon, Moon, Sun, X } from "lucide-react"; import { cn } from "@/lib/cn"; import type { ShellData } from "@/lib/appShell"; import { activeNavKey } from "@/lib/nav"; @@ -14,6 +14,7 @@ import { SpaceWorksLogo } from "@/components/brand/SpaceWorksLogo"; import { useAppColorMode } from "@/components/providers"; import { Avatar } from "@/components/ui/display"; import { IconButton } from "@/components/ui/button"; +import { useRememberedOpen } from "@/components/ui/collapsible"; import { Menu, MenuContent, MenuItem, MenuLabel, MenuSeparator, MenuTrigger } from "@/components/ui/menu"; function NavLink({ href, active, children, onNavigate }: { href: string; active: boolean; children: string; onNavigate?: () => void }) { @@ -130,6 +131,29 @@ function NotificationsLink({ data, onNavigate }: { data: ShellData; onNavigate?: ); } +/** A labelled sidebar group that folds; the group holding the current page always shows it. */ +function NavGroup({ id, label, holdsActive, children }: { id: string; label: string; holdsActive: boolean; children: ReactNode }) { + const [saved, toggle] = useRememberedOpen(`swi:nav:${id}`, true); + const open = saved || holdsActive; + const listId = useId(); + return ( +
    + + +
    + ); +} + function SidebarContents({ data, onNavigate, headerAction }: { data: ShellData; onNavigate?: () => void; headerAction?: ReactNode }) { const pathname = usePathname() ?? ""; const active = activeNavKey(data.nav, pathname); @@ -145,16 +169,20 @@ function SidebarContents({ data, onNavigate, headerAction }: { data: ShellData; {headerAction ?? }
    diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx index 9624861..2472290 100644 --- a/components/ui/collapsible.tsx +++ b/components/ui/collapsible.tsx @@ -4,11 +4,9 @@ import { useId, useState, useSyncExternalStore, type ReactNode } from "react"; import { ChevronDown } from "lucide-react"; import { cn } from "@/lib/cn"; -const STORAGE_PREFIX = "swi:dash:"; - -function readSaved(id: string): boolean | null { +function readSaved(key: string): boolean | null { try { - const saved = window.localStorage.getItem(STORAGE_PREFIX + id); + const saved = window.localStorage.getItem(key); return saved === "open" ? true : saved === "closed" ? false : null; } catch { return null; @@ -20,14 +18,29 @@ function subscribe(onChange: () => void) { return () => window.removeEventListener("storage", onChange); } -function save(id: string, open: boolean) { +function save(key: string, open: boolean) { try { - window.localStorage.setItem(STORAGE_PREFIX + id, open ? "open" : "closed"); + window.localStorage.setItem(key, open ? "open" : "closed"); } catch { // Private windows and blocked storage just forget the choice. } } +/** + * Open/closed state remembered in this browser under `storageKey`. The server + * and the first client render use `defaultOpen`; the saved choice applies + * after hydration. + */ +export function useRememberedOpen(storageKey: string, defaultOpen: boolean): [boolean, () => void] { + const saved = useSyncExternalStore(subscribe, () => readSaved(storageKey), () => null); + const [choice, setChoice] = useState(null); + const open = choice ?? saved ?? defaultOpen; + return [open, () => { + save(storageKey, !open); + setChoice(!open); + }]; +} + /** * A card whose body folds away behind its title. Starts from `defaultOpen` * (empty sections start closed) and then remembers this browser's choice. @@ -43,17 +56,9 @@ export function CollapsibleCard({ titleAs?: "h2" | "h3"; children: ReactNode; }) { - // The server and the first client render use defaultOpen; the saved choice applies after hydration. - const saved = useSyncExternalStore(subscribe, () => readSaved(id), () => null); - const [choice, setChoice] = useState(null); - const open = choice ?? saved ?? defaultOpen; + const [open, toggle] = useRememberedOpen(`swi:dash:${id}`, defaultOpen); const bodyId = useId(); - function toggle() { - save(id, !open); - setChoice(!open); - } - return (
    From 14b623385caa5172c98d05d007438d1b3733baac Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Sun, 25 Oct 2026 18:14:10 +0530 Subject: [PATCH 05/11] feat(ui): secondary sections fold on every busy page; long tables keep their header in view Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++ README.md | 20 +++++--- app/account/page.tsx | 17 +++++-- app/s/[spaceSlug]/assets/[assetId]/page.tsx | 28 +++++----- .../catalogue/self-checkout/page.tsx | 2 +- app/s/[spaceSlug]/counts/[countId]/page.tsx | 2 +- app/s/[spaceSlug]/items/[itemId]/page.tsx | 38 ++++++-------- app/s/[spaceSlug]/loans/[loanId]/page.tsx | 2 +- app/s/[spaceSlug]/members/page.tsx | 8 +-- app/s/[spaceSlug]/my-loans/[loanId]/page.tsx | 2 +- app/s/[spaceSlug]/my-loans/page.tsx | 13 +++-- .../purchases/_components/PurchasesTable.tsx | 2 +- app/s/[spaceSlug]/repairs/[issueId]/page.tsx | 18 +++---- app/s/[spaceSlug]/repairs/page.tsx | 2 +- app/s/[spaceSlug]/repairs/payouts/page.tsx | 2 +- .../[spaceSlug]/repairs/technicians/page.tsx | 8 +-- app/s/[spaceSlug]/settings/roles/page.tsx | 8 +-- .../transfers/[transferId]/page.tsx | 10 ++-- components/catalogue/AssetsTable.tsx | 2 +- components/catalogue/ItemsTable.tsx | 2 +- components/catalogue/LocationsView.tsx | 4 +- components/counts/SchedulesCard.tsx | 12 ++--- components/desk/EvidenceGallery.tsx | 8 +-- components/desk/Timeline.tsx | 9 ++-- components/finance/FinanceEntries.tsx | 10 ++-- components/finance/FinanceToolbar.tsx | 5 +- components/integrations/ApiKeysPanel.tsx | 12 ++--- components/integrations/SignatureHelp.tsx | 8 +-- components/integrations/WebhooksPanel.tsx | 12 ++--- components/loans/IncidentList.tsx | 8 +-- components/loans/LoanFacts.tsx | 8 +-- components/loans/LoanQueueTable.tsx | 2 +- components/payments/LoanPaymentPanel.tsx | 8 +-- components/repairs/MoneyPanel.tsx | 11 ++-- components/repairs/RequestsPanel.tsx | 8 +-- components/reports/ReportTableView.tsx | 13 +++-- components/settings/MembersTable.tsx | 2 +- components/stock/MovementsTable.tsx | 2 +- components/stock/RepairShelf.tsx | 13 +++-- components/ui/collapsible.tsx | 51 ++++++++++++++++++- components/ui/layouts.tsx | 3 +- components/ui/table.tsx | 11 ++-- docs/DECISIONS.md | 6 +++ frontend/contracts.d.ts | 2 +- frontend/pages.json | 30 +++++------ tests/e2e/member-loans.spec.ts | 2 +- tests/unit/design-fidelity.test.ts | 2 +- 47 files changed, 257 insertions(+), 198 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fc7f9e..8541e87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,13 @@ The first public release: everything since the split from SpaceWorks collapsed, show their count and remember this browser's choice. - The notifications link beside the space name is a bell icon instead of the word "Inbox". +- Secondary sections fold on every busy page and remember the choice: item, + asset, loan, transfer and repair details, the timeline and evidence, + report and finance tables, integrations, members' invitations, count + schedules, the repair shelf and the account page. Long lists (items, + assets, loans, stock movements, members, the buy list, repairs and the + ledger) scroll inside their box with the header row kept in view. +- Back links and the location tree use icons instead of arrow characters. - **Roles: Inventory manager, Staff and Member, plus Technician** (repairs only, with an authenticator app). The admin and owner roles are gone; the inventory manager runs the whole space, including members, other managers and deleting the space. The space's creator is its diff --git a/README.md b/README.md index 6924008..5166f86 100644 --- a/README.md +++ b/README.md @@ -83,13 +83,21 @@ signed webhooks connect it to the other Space Works apps ### Run it your way -- Space-manager settings for the whole makerspace (lending policy, member - eligibility, evidence rules, fees, payment providers, notifications, - branding) and inventory-manager settings for the stock side (categories, - units, thresholds, transfer approvals, labels, count schedules). Every +- Settings for the whole makerspace (lending policy, member eligibility, + evidence rules, fees, payment providers, notifications, branding) and for + the stock side (categories, units, thresholds, transfer approvals, labels, + count schedules, repair requests), all run by the inventory manager. Every change is audited. -- Repairs: report damaged items (also from loan incidents), a technician directory, requests by email, WhatsApp and Telegram, a private link for external technicians, verified fixes and payouts -- Finance (inventory managers only): one ledger for every payment, refund, payout, purchase and manual entry, with income, expenses and net by period, category, method, item, technician and supplier, and CSV or Excel downloads +- Repairs: report damaged items (also from loan incidents), keep a directory + of technicians, send requests by email, WhatsApp and Telegram, give external + technicians a private link, verify fixes and pay them. +- Finance (inventory managers only): one ledger for every payment, refund, + payout, purchase and manual entry, with income, expenses and net by period, + category, method, item, technician and supplier, and CSV or Excel downloads. +- A calm interface: every page's secondary sections fold away and remember + your choice, long tables keep their header row in view, the sidebar groups + fold, notifications sit behind a bell, and light and dark mode switch with + a soft circle that grows from the toggle. - Roles: Inventory manager, Staff, Member and Technician ([role matrix](docs/RBAC.md)). - Reports with CSV and Excel exports, an audit log, email and in-app diff --git a/app/account/page.tsx b/app/account/page.tsx index 02db730..7d56b87 100644 --- a/app/account/page.tsx +++ b/app/account/page.tsx @@ -6,7 +6,8 @@ import { PasswordForm } from "@/components/account/PasswordForm"; import { ProfileForm } from "@/components/account/ProfileForm"; import { SessionsCard } from "@/components/account/SessionsCard"; import { Button } from "@/components/ui/button"; -import { Card, PageHeader, SettingsRow } from "@/components/ui/display"; +import { CollapsibleCard } from "@/components/ui/collapsible"; +import { PageHeader, SettingsRow } from "@/components/ui/display"; import { prisma } from "@/lib/db"; import { remainingRecoveryCodes } from "@/lib/mfa"; import { requireAuth } from "@/lib/permissions"; @@ -30,7 +31,7 @@ export default async function AccountPage() {
    - + @@ -40,9 +41,13 @@ export default async function AccountPage() { - + + + + + @@ -51,7 +56,9 @@ export default async function AccountPage() { Download my data - + + + {spaces.length === 0 ? (

    You aren't in any space yet.

    ) : ( @@ -65,7 +72,7 @@ export default async function AccountPage() { )}
    -
    +
    ); diff --git a/app/s/[spaceSlug]/assets/[assetId]/page.tsx b/app/s/[spaceSlug]/assets/[assetId]/page.tsx index a3b4fb8..03ddf25 100644 --- a/app/s/[spaceSlug]/assets/[assetId]/page.tsx +++ b/app/s/[spaceSlug]/assets/[assetId]/page.tsx @@ -8,7 +8,8 @@ import { CONDITION_LABEL } from "@/components/catalogue/words"; import { AssetStatusChip } from "@/components/inventory/StatusChips"; import { MovementsTable } from "@/components/stock/MovementsTable"; import { Button } from "@/components/ui/button"; -import { Card, CardHeader, DetailList, PageHeader } from "@/components/ui/display"; +import { CollapsibleCard } from "@/components/ui/collapsible"; +import { DetailList, PageHeader } from "@/components/ui/display"; import { DetailLayout } from "@/components/ui/layouts"; import { loanRef, transferRef } from "@/lib/catalogue/references"; import { loadMovements, locationOptions, qrSvg } from "@/lib/catalogue/views"; @@ -67,33 +68,28 @@ export default async function AssetPage({ params }: PageProps<"/s/[spaceSlug]/as } aside={
    - - + - - - + + {header.notes ?

    {header.notes}

    : null} -
    +
    } >
    - - Full ledger} /> + Full ledger} count={movements.length} defaultOpen={movements.length > 0}> {movements.length === 0 ?

    No movements yet.

    : } -
    - - + + 0}> - - - + + 0}> - +
    ); diff --git a/app/s/[spaceSlug]/catalogue/self-checkout/page.tsx b/app/s/[spaceSlug]/catalogue/self-checkout/page.tsx index 9d7b93e..e716e77 100644 --- a/app/s/[spaceSlug]/catalogue/self-checkout/page.tsx +++ b/app/s/[spaceSlug]/catalogue/self-checkout/page.tsx @@ -24,7 +24,7 @@ export default async function SelfCheckoutPage({ params }: PageProps<"/s/[spaceS return ( } > {off ? ( diff --git a/app/s/[spaceSlug]/counts/[countId]/page.tsx b/app/s/[spaceSlug]/counts/[countId]/page.tsx index 42b442b..9faa0f6 100644 --- a/app/s/[spaceSlug]/counts/[countId]/page.tsx +++ b/app/s/[spaceSlug]/counts/[countId]/page.tsx @@ -50,7 +50,7 @@ export default async function CountPage({ params }: PageProps<"/s/[spaceSlug]/co return ( } />} aside={} > diff --git a/app/s/[spaceSlug]/items/[itemId]/page.tsx b/app/s/[spaceSlug]/items/[itemId]/page.tsx index 9e78235..4619919 100644 --- a/app/s/[spaceSlug]/items/[itemId]/page.tsx +++ b/app/s/[spaceSlug]/items/[itemId]/page.tsx @@ -9,7 +9,8 @@ import { QrPlate } from "@/components/catalogue/QrPlate"; import { RENTAL_MODE_LABEL } from "@/components/catalogue/words"; import { MovementsTable } from "@/components/stock/MovementsTable"; import { Button } from "@/components/ui/button"; -import { Card, CardHeader, Chip, DetailList, PageHeader, StatTile } from "@/components/ui/display"; +import { CollapsibleCard } from "@/components/ui/collapsible"; +import { Chip, DetailList, PageHeader, StatTile } from "@/components/ui/display"; import { DetailLayout } from "@/components/ui/layouts"; import { itemImageUrl } from "@/lib/catalogue/images"; import { loadMovements, locationOptions, qrSvg } from "@/lib/catalogue/views"; @@ -100,20 +101,17 @@ export default async function ItemPage({ params }: PageProps<"/s/[spaceSlug]/ite } aside={
    - - + - - - + + - + {lending.length > 0 ? ( - - + - + ) : null}
    } @@ -131,13 +129,11 @@ export default async function ItemPage({ params }: PageProps<"/s/[spaceSlug]/ite
    - - + - + {header.tracking === "SERIALIZED" ? ( - - + {canEdit && !archived ? (
    @@ -145,17 +141,15 @@ export default async function ItemPage({ params }: PageProps<"/s/[spaceSlug]/ite
    ) : null} -
    + ) : null} - - Full ledger} /> + 0} action={}> {movements.length === 0 ?

    No stock movements yet.

    : } -
    + {header.notes ? ( - - +

    {header.notes}

    -
    + ) : null}
    diff --git a/app/s/[spaceSlug]/loans/[loanId]/page.tsx b/app/s/[spaceSlug]/loans/[loanId]/page.tsx index 0aa92b9..527700b 100644 --- a/app/s/[spaceSlug]/loans/[loanId]/page.tsx +++ b/app/s/[spaceSlug]/loans/[loanId]/page.tsx @@ -91,7 +91,7 @@ export default async function LoanPage({ params, searchParams }: PageProps<"/s/[ return ( {reference}
    } description={`${loan.borrower.name} · ${facts.summary}`} />} aside={
    diff --git a/app/s/[spaceSlug]/members/page.tsx b/app/s/[spaceSlug]/members/page.tsx index 1ba7840..fca099b 100644 --- a/app/s/[spaceSlug]/members/page.tsx +++ b/app/s/[spaceSlug]/members/page.tsx @@ -2,7 +2,8 @@ import Link from "next/link"; import { InvitationsPanel } from "@/components/settings/InvitationsPanel"; import { MembersTable } from "@/components/settings/MembersTable"; import { Button } from "@/components/ui/button"; -import { PageHeader, SectionHeading } from "@/components/ui/display"; +import { CollapsibleSection } from "@/components/ui/collapsible"; +import { PageHeader } from "@/components/ui/display"; import { Dropdown, Field, Input } from "@/components/ui/field"; import { ListLayout } from "@/components/ui/layouts"; import { formatDate, formatRelativeTime } from "@/lib/format/datetime"; @@ -74,10 +75,9 @@ export default async function MembersPage({ params, searchParams }: PageProps<"/ > {isAdmin ? ( -
    - + -
    + ) : null} ); diff --git a/app/s/[spaceSlug]/my-loans/[loanId]/page.tsx b/app/s/[spaceSlug]/my-loans/[loanId]/page.tsx index 32a94cc..ddecd99 100644 --- a/app/s/[spaceSlug]/my-loans/[loanId]/page.tsx +++ b/app/s/[spaceSlug]/my-loans/[loanId]/page.tsx @@ -32,7 +32,7 @@ export default async function MyLoanPage({ params, searchParams }: PageProps<"/s return ( {loan.reference}} description={loan.facts.summary} />} aside={
    diff --git a/app/s/[spaceSlug]/my-loans/page.tsx b/app/s/[spaceSlug]/my-loans/page.tsx index 6b0fe83..6995b1f 100644 --- a/app/s/[spaceSlug]/my-loans/page.tsx +++ b/app/s/[spaceSlug]/my-loans/page.tsx @@ -1,7 +1,8 @@ import Link from "next/link"; import { MyLoansTable } from "@/components/loans/MyLoansTable"; import { buttonClasses } from "@/components/ui/button"; -import { Callout, EmptyState, PageHeader, SectionHeading } from "@/components/ui/display"; +import { CollapsibleSection } from "@/components/ui/collapsible"; +import { Callout, EmptyState, PageHeader } from "@/components/ui/display"; import { ListLayout } from "@/components/ui/layouts"; import { loanRef } from "@/lib/catalogue/references"; import { prisma } from "@/lib/db"; @@ -43,15 +44,13 @@ export default async function MyLoansPage({ params }: PageProps<"/s/[spaceSlug]/ Open the catalogue : undefined}>Borrow a tool from the catalogue and it shows up here. ) : (
    -
    - + {open.length ? : Nothing out right now.} -
    + {past.length ? ( -
    - + -
    + ) : null}
    )} diff --git a/app/s/[spaceSlug]/purchases/_components/PurchasesTable.tsx b/app/s/[spaceSlug]/purchases/_components/PurchasesTable.tsx index 2655852..67947cc 100644 --- a/app/s/[spaceSlug]/purchases/_components/PurchasesTable.tsx +++ b/app/s/[spaceSlug]/purchases/_components/PurchasesTable.tsx @@ -81,7 +81,7 @@ export function PurchasesTable({ slug, currency, rows, manage, locations, suppli return ( <> - +
    diff --git a/app/s/[spaceSlug]/repairs/[issueId]/page.tsx b/app/s/[spaceSlug]/repairs/[issueId]/page.tsx index 0e41ae9..ab5c360 100644 --- a/app/s/[spaceSlug]/repairs/[issueId]/page.tsx +++ b/app/s/[spaceSlug]/repairs/[issueId]/page.tsx @@ -7,7 +7,8 @@ import { RequestsPanel } from "@/components/repairs/RequestsPanel"; import { MoneyPanel } from "@/components/repairs/MoneyPanel"; import { WorkPanel } from "@/components/repairs/WorkPanel"; import { StatusPanel } from "@/components/repairs/StatusPanel"; -import { Callout, Card, CardHeader, DetailList, PageHeader } from "@/components/ui/display"; +import { CollapsibleCard } from "@/components/ui/collapsible"; +import { Callout, DetailList, PageHeader } from "@/components/ui/display"; import { DetailLayout } from "@/components/ui/layouts"; import { prisma } from "@/lib/db"; import { requireSpace } from "@/lib/permissions"; @@ -51,8 +52,7 @@ export default async function RepairPage({ params }: PageProps<"/s/[spaceSlug]/r } >
    - - + {repair.item.name} ({repair.item.sku}) }, @@ -77,21 +77,19 @@ export default async function RepairPage({ params }: PageProps<"/s/[spaceSlug]/r ))} ) : null} - + {repair.fix.remarks || canWork ? ( - - + {repair.fix.remarks ?

    {repair.fix.remarks}

    : null} {repair.fix.partsUsed ?

    Parts: {repair.fix.partsUsed}

    : null} {repair.closeNote ?

    Closing note: {repair.closeNote}

    : null} {canWork ? : null} -
    + ) : null} {repair.purchases.length ? ( - - +
      {repair.purchases.map((purchase) =>
    • {purchase.name} · {purchase.status.toLowerCase()}
    • )}
    -
    + ) : null}
    diff --git a/app/s/[spaceSlug]/repairs/page.tsx b/app/s/[spaceSlug]/repairs/page.tsx index b65aec2..6487fd2 100644 --- a/app/s/[spaceSlug]/repairs/page.tsx +++ b/app/s/[spaceSlug]/repairs/page.tsx @@ -50,7 +50,7 @@ export default async function RepairsPage({ params, searchParams }: PageProps<"/ {rows.length === 0 ? ( {canReport ? "Report a problem when something is damaged or broken." : "Requests sent to you show up here."} ) : ( -
    Purchase
    +
    diff --git a/app/s/[spaceSlug]/repairs/payouts/page.tsx b/app/s/[spaceSlug]/repairs/payouts/page.tsx index ebad0d8..42c441d 100644 --- a/app/s/[spaceSlug]/repairs/payouts/page.tsx +++ b/app/s/[spaceSlug]/repairs/payouts/page.tsx @@ -28,7 +28,7 @@ export default async function RepairPayoutsPage({ params }: PageProps<"/s/[space return ( }>
    - + 0 ? "attention" : "default"} />
    {payouts.length === 0 ? Approve a fixed repair's amount, then record the payout on the repair. : ( diff --git a/app/s/[spaceSlug]/repairs/technicians/page.tsx b/app/s/[spaceSlug]/repairs/technicians/page.tsx index 1df21b6..5dc02da 100644 --- a/app/s/[spaceSlug]/repairs/technicians/page.tsx +++ b/app/s/[spaceSlug]/repairs/technicians/page.tsx @@ -2,7 +2,8 @@ import type { Metadata } from "next"; import { MessagingCard } from "@/components/repairs/MessagingCard"; import { TechnicianDialog } from "@/components/repairs/TechnicianDialog"; import { TechniciansTable } from "@/components/repairs/TechniciansTable"; -import { PageHeader, SectionHeading } from "@/components/ui/display"; +import { CollapsibleSection } from "@/components/ui/collapsible"; +import { PageHeader } from "@/components/ui/display"; import { ListLayout } from "@/components/ui/layouts"; import { prisma } from "@/lib/db"; import { requireSpace } from "@/lib/permissions"; @@ -41,10 +42,9 @@ export default async function TechniciansPage({ params }: PageProps<"/s/[spaceSl header={} />} > -
    - + -
    +
    ); } diff --git a/app/s/[spaceSlug]/settings/roles/page.tsx b/app/s/[spaceSlug]/settings/roles/page.tsx index c150a55..5105e87 100644 --- a/app/s/[spaceSlug]/settings/roles/page.tsx +++ b/app/s/[spaceSlug]/settings/roles/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { SettingsTabs } from "@/components/settings/SettingsTabs"; -import { Card, CardHeader, Chip, PageHeader, SectionHeading } from "@/components/ui/display"; +import { CollapsibleCard } from "@/components/ui/collapsible"; +import { Chip, PageHeader, SectionHeading } from "@/components/ui/display"; import { SettingsLayout } from "@/components/ui/layouts"; import { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table"; import { requireSpace } from "@/lib/permissions"; @@ -26,8 +27,7 @@ export default async function RolesPage({ params }: PageProps<"/s/[spaceSlug]/se title="Roles" description="Each person has one role in this space. Higher roles can do everything the roles below them can. Borrowing standing is separate from the role." /> - - Change someone's role on the members page.} /> + Change someone's role on the members page.}>
    {roleCards.map((card) => (
    @@ -36,7 +36,7 @@ export default async function RolesPage({ params }: PageProps<"/s/[spaceSlug]/se
    ))}
    -
    +
    Repair
    diff --git a/app/s/[spaceSlug]/transfers/[transferId]/page.tsx b/app/s/[spaceSlug]/transfers/[transferId]/page.tsx index c44cbce..a68f2c4 100644 --- a/app/s/[spaceSlug]/transfers/[transferId]/page.tsx +++ b/app/s/[spaceSlug]/transfers/[transferId]/page.tsx @@ -5,7 +5,8 @@ import { Timeline } from "@/components/desk/Timeline"; import { TransferStatusChip } from "@/components/inventory/StatusChips"; import { TransferLinesTable } from "@/components/transfers/TransferLinesTable"; import { TransferSteps } from "@/components/transfers/TransferSteps"; -import { Callout, Card, CardHeader, DetailList, PageHeader } from "@/components/ui/display"; +import { CollapsibleCard } from "@/components/ui/collapsible"; +import { Callout, DetailList, PageHeader } from "@/components/ui/display"; import { DetailLayout } from "@/components/ui/layouts"; import { transferRef } from "@/lib/catalogue/references"; import { locationOptions } from "@/lib/desk/locations"; @@ -63,14 +64,13 @@ export default async function TransferPage({ params, searchParams }: PageProps<" return ( Transfer } title={reference} description={transfer.betweenSpaces ? `${transfer.source.name} to ${transfer.dest.name}` : "Between shelves of this space"} />} aside={
    - - + - + {otherHref ? Open as {otherName} : null}
    } diff --git a/components/catalogue/AssetsTable.tsx b/components/catalogue/AssetsTable.tsx index b5bf269..907c802 100644 --- a/components/catalogue/AssetsTable.tsx +++ b/components/catalogue/AssetsTable.tsx @@ -9,7 +9,7 @@ export type AssetListRow = { id: string; assetTag: string; serialNumber: string /** Serialized units across the space; the asset tag is the row link. */ export function AssetsTable({ slug, rows }: { slug: string; rows: AssetListRow[] }) { return ( -
    +
    diff --git a/components/catalogue/ItemsTable.tsx b/components/catalogue/ItemsTable.tsx index 5361caa..fe52ce6 100644 --- a/components/catalogue/ItemsTable.tsx +++ b/components/catalogue/ItemsTable.tsx @@ -14,7 +14,7 @@ const qty = (value: number, unit: string | null) => `${value.toLocaleString("en- /** The catalogue with stock in every state and its value at average cost. */ export function ItemsTable({ slug, rows }: { slug: string; rows: ItemListRow[] }) { return ( -
    Asset tagItemSerial numberStatusConditionLocation
    +
    diff --git a/components/catalogue/LocationsView.tsx b/components/catalogue/LocationsView.tsx index 8217921..a274ac8 100644 --- a/components/catalogue/LocationsView.tsx +++ b/components/catalogue/LocationsView.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import Link from "next/link"; -import { Ellipsis } from "lucide-react"; +import { Ellipsis, CornerDownRight } from "lucide-react"; import { setLocationArchivedAction } from "@/app/actions/catalogue"; import { Button, IconButton } from "@/components/ui/button"; import { Chip, EmptyState, PageHeader } from "@/components/ui/display"; @@ -55,7 +55,7 @@ export function LocationsView({ slug, rows, parents, canEdit, showArchived }: {
    Item
    - {row.depth > 0 ? └ : null} + {row.depth > 0 ? : null} {row.name} {row.archived ? Archived : null} diff --git a/components/counts/SchedulesCard.tsx b/components/counts/SchedulesCard.tsx index f89c7fd..fd6e737 100644 --- a/components/counts/SchedulesCard.tsx +++ b/components/counts/SchedulesCard.tsx @@ -4,7 +4,8 @@ import { setCountScheduleActiveAction } from "@/app/actions/counts"; import type { LocationOption } from "@/components/desk/LocationPicker"; import { useActionRunner } from "@/components/desk/useActionRunner"; import { Button } from "@/components/ui/button"; -import { Callout, Card, CardHeader, Chip } from "@/components/ui/display"; +import { CollapsibleCard } from "@/components/ui/collapsible"; +import { Callout, Chip } from "@/components/ui/display"; import { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table"; import { ScheduleDialog } from "./ScheduleDialog"; @@ -14,12 +15,7 @@ export type ScheduleRow = { id: string; name: string; scope: string; everyDays: export function SchedulesCard({ slug, schedules, locations, categories }: { slug: string; schedules: ScheduleRow[]; locations: LocationOption[]; categories: Array<{ id: string; name: string }> }) { const { run, pending, error } = useActionRunner(); return ( - - } - /> + }> {error ? {error} : null} {schedules.length === 0 ? (

    No schedules yet.

    @@ -61,6 +57,6 @@ export function SchedulesCard({ slug, schedules, locations, categories }: { slug
    )} - + ); } diff --git a/components/desk/EvidenceGallery.tsx b/components/desk/EvidenceGallery.tsx index b795488..a1f4b40 100644 --- a/components/desk/EvidenceGallery.tsx +++ b/components/desk/EvidenceGallery.tsx @@ -1,5 +1,6 @@ import Image from "next/image"; -import { Card, CardHeader, EmptyState } from "@/components/ui/display"; +import { CollapsibleCard } from "@/components/ui/collapsible"; +import { EmptyState } from "@/components/ui/display"; export type EvidenceItem = { id: string; @@ -18,8 +19,7 @@ export type EvidenceItem = { */ export function EvidenceGallery({ items, title = "Evidence" }: { items: EvidenceItem[]; title?: string }) { return ( - - + 0} description="Photos and scans recorded at each handover."> {items.length === 0 ? ( No handovers recorded yet. ) : ( @@ -44,6 +44,6 @@ export function EvidenceGallery({ items, title = "Evidence" }: { items: Evidence ))} )} - + ); } diff --git a/components/desk/Timeline.tsx b/components/desk/Timeline.tsx index d121b34..bfce50b 100644 --- a/components/desk/Timeline.tsx +++ b/components/desk/Timeline.tsx @@ -1,12 +1,11 @@ -import { Card, CardHeader } from "@/components/ui/display"; +import { CollapsibleCard } from "@/components/ui/collapsible"; export type TimelineEntry = { id: string; title: string; detail?: string | null; actorName: string; whenLabel: string }; -/** The audit trail of one record, newest first. */ +/** The audit trail of one record, newest first; folds away and remembers it. */ export function Timeline({ entries, title = "Timeline" }: { entries: TimelineEntry[]; title?: string }) { return ( - - + {entries.length === 0 ? (

    Nothing has happened yet.

    ) : ( @@ -21,6 +20,6 @@ export function Timeline({ entries, title = "Timeline" }: { entries: TimelineEnt ))} )} -
    + ); } diff --git a/components/finance/FinanceEntries.tsx b/components/finance/FinanceEntries.tsx index c6a2286..40e7ed7 100644 --- a/components/finance/FinanceEntries.tsx +++ b/components/finance/FinanceEntries.tsx @@ -5,7 +5,8 @@ import Link from "next/link"; import { reverseFinanceEntryAction } from "@/app/actions/finance"; import { useActionRunner } from "@/components/desk/useActionRunner"; import { Button } from "@/components/ui/button"; -import { Callout, Chip, EmptyState, SectionHeading } from "@/components/ui/display"; +import { CollapsibleSection } from "@/components/ui/collapsible"; +import { Callout, Chip, EmptyState } from "@/components/ui/display"; import { Field, Input } from "@/components/ui/field"; import { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table"; import type { FinanceReport } from "@/lib/finance/report"; @@ -18,11 +19,10 @@ export function FinanceEntries({ slug, currency, entries }: { slug: string; curr const [reason, setReason] = useState(""); const { run, pending, error } = useActionRunner(); return ( -
    - + {error ? {error} : null} {entries.length === 0 ? : ( - +
    @@ -53,6 +53,6 @@ export function FinanceEntries({ slug, currency, entries }: { slug: string; curr
    DateEntryMethodAmountActions
    )} -
    + ); } diff --git a/components/finance/FinanceToolbar.tsx b/components/finance/FinanceToolbar.tsx index 27886ab..e026ba8 100644 --- a/components/finance/FinanceToolbar.tsx +++ b/components/finance/FinanceToolbar.tsx @@ -1,5 +1,6 @@ import { Button } from "@/components/ui/button"; import { Field, Input } from "@/components/ui/field"; +import { Dropdown } from "@/components/ui/select"; import { PERIOD_GROUPS, type PeriodGroup } from "@/lib/finance/periods"; /** Period, grouping and the CSV / XLSX downloads (a plain GET form, so it works without JavaScript). */ @@ -10,9 +11,9 @@ export function FinanceToolbar({ fromDay, toDay, group, exportBase }: { fromDay: - + diff --git a/components/integrations/ApiKeysPanel.tsx b/components/integrations/ApiKeysPanel.tsx index 51fb48b..1c840f8 100644 --- a/components/integrations/ApiKeysPanel.tsx +++ b/components/integrations/ApiKeysPanel.tsx @@ -5,7 +5,8 @@ import { useRouter } from "next/navigation"; import { revokeApiKeyAction } from "@/app/actions/integrations"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/dialog"; -import { Card, CardHeader, Chip, EmptyState } from "@/components/ui/display"; +import { CollapsibleCard } from "@/components/ui/collapsible"; +import { Chip, EmptyState } from "@/components/ui/display"; import { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table"; import { useToast } from "@/components/feedback/ToastProvider"; import { CreateApiKeyDialog } from "./CreateApiKeyDialog"; @@ -24,12 +25,7 @@ export function ApiKeysPanel({ slug, keys }: { slug: string; keys: ApiKeyRow[] } const [pending, startTransition] = useTransition(); return ( - - API keys} - description="For scripts and other tools that read or change this space through the REST API at /api/v1." - action={} - /> + }> {keys.length === 0 ? ( Create one when a tool needs to reach this space. ) : ( @@ -87,6 +83,6 @@ export function ApiKeysPanel({ slug, keys }: { slug: string; keys: ApiKeyRow[] } }); }} /> - + ); } diff --git a/components/integrations/SignatureHelp.tsx b/components/integrations/SignatureHelp.tsx index 2a42431..1c6ecec 100644 --- a/components/integrations/SignatureHelp.tsx +++ b/components/integrations/SignatureHelp.tsx @@ -1,4 +1,5 @@ -import { Card, CardHeader, DetailList } from "@/components/ui/display"; +import { CollapsibleCard } from "@/components/ui/collapsible"; +import { DetailList } from "@/components/ui/display"; const SNIPPET = `import crypto from "node:crypto"; @@ -16,8 +17,7 @@ function verify(secret, rawBody, header) { /** How receivers check that an event really came from this space. */ export function SignatureHelp() { return ( - - Verifying signatures} description="Every event is a POST with a JSON body and these headers. Check the signature before trusting the body." /> + t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of “t.raw body” using the signing secret. }, @@ -27,6 +27,6 @@ export function SignatureHelp() { />

    Reject events whose timestamp is more than five minutes old. Answer with any 2xx status within ten seconds; anything else is retried with growing gaps, and a webhook that keeps failing is switched off.

    {SNIPPET}
    -
    + ); } diff --git a/components/integrations/WebhooksPanel.tsx b/components/integrations/WebhooksPanel.tsx index e97d50d..0eed1f9 100644 --- a/components/integrations/WebhooksPanel.tsx +++ b/components/integrations/WebhooksPanel.tsx @@ -1,16 +1,12 @@ -import { Card, CardHeader, EmptyState } from "@/components/ui/display"; +import { CollapsibleCard } from "@/components/ui/collapsible"; +import { EmptyState } from "@/components/ui/display"; import { CreateWebhookDialog } from "./CreateWebhookDialog"; import { EndpointItem } from "./EndpointItem"; import type { EndpointRow } from "./types"; export function WebhooksPanel({ slug, endpoints, max }: { slug: string; endpoints: EndpointRow[]; max: number }) { return ( - - Webhooks} - description="Signed events for loans, stock, transfers and payments. Payloads carry ids, numbers and quantities, never emails or remarks." - action={= max} />} - /> + = max} />}> {endpoints.length === 0 ? ( Add one to send events to another app. ) : ( @@ -18,6 +14,6 @@ export function WebhooksPanel({ slug, endpoints, max }: { slug: string; endpoint {endpoints.map((endpoint) => )} )} - + ); } diff --git a/components/loans/IncidentList.tsx b/components/loans/IncidentList.tsx index 3f344a7..1f5a302 100644 --- a/components/loans/IncidentList.tsx +++ b/components/loans/IncidentList.tsx @@ -4,7 +4,8 @@ import { useState } from "react"; import { resolveIncidentAction } from "@/app/actions/loans"; import type { LocationOption } from "@/components/desk/LocationPicker"; import { Button } from "@/components/ui/button"; -import { Card, CardHeader, Chip } from "@/components/ui/display"; +import { CollapsibleCard } from "@/components/ui/collapsible"; +import { Chip } from "@/components/ui/display"; import { useActionRunner } from "@/components/desk/useActionRunner"; import { ResolveIncidentDialog } from "./ResolveIncidentDialog"; @@ -19,8 +20,7 @@ export function IncidentList({ slug, incidents, canResolve, locations }: { slug: const runner = useActionRunner(); if (incidents.length === 0) return null; return ( - - +
      {incidents.map((incident) => (
    • @@ -48,6 +48,6 @@ export function IncidentList({ slug, incidents, canResolve, locations }: { slug: onConfirm={(input) => void runner.run(() => resolveIncidentAction(slug, { incidentId: resolving.id, ...input }), "Incident resolved.").then((ok) => { if (ok) setResolving(null); })} /> ) : null} - + ); } diff --git a/components/loans/LoanFacts.tsx b/components/loans/LoanFacts.tsx index 973f5d5..af26a2e 100644 --- a/components/loans/LoanFacts.tsx +++ b/components/loans/LoanFacts.tsx @@ -1,6 +1,7 @@ import type { BorrowingStatus } from "@prisma/client"; import { BorrowingChip } from "@/components/inventory/StatusChips"; -import { Card, CardHeader, DetailList } from "@/components/ui/display"; +import { CollapsibleCard } from "@/components/ui/collapsible"; +import { DetailList } from "@/components/ui/display"; export type LoanFactsView = { borrower: { name: string; email: string } | null; @@ -17,9 +18,8 @@ export function LoanFacts({ view }: { view: LoanFactsView }) { ...view.facts.map((fact) => ({ label: fact.label, value: fact.value })), ]; return ( - - + - + ); } diff --git a/components/loans/LoanQueueTable.tsx b/components/loans/LoanQueueTable.tsx index 8484763..fd6e51e 100644 --- a/components/loans/LoanQueueTable.tsx +++ b/components/loans/LoanQueueTable.tsx @@ -11,7 +11,7 @@ export type LoanQueueRow = { /** The desk queue: the reference is the row link; status chips carry a word. */ export function LoanQueueTable({ slug, rows, dateHeading }: { slug: string; rows: LoanQueueRow[]; dateHeading: string }) { return ( - +
      diff --git a/components/payments/LoanPaymentPanel.tsx b/components/payments/LoanPaymentPanel.tsx index 592e1e1..0b16ce9 100644 --- a/components/payments/LoanPaymentPanel.tsx +++ b/components/payments/LoanPaymentPanel.tsx @@ -8,6 +8,7 @@ import { } from "@/app/actions/payments"; import { useToast } from "@/components/feedback/ToastProvider"; import { Button } from "@/components/ui/button"; +import { CollapsibleCard } from "@/components/ui/collapsible"; import { Card, CardHeader, Chip, DetailList } from "@/components/ui/display"; import { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table"; import { formatMoney } from "@/lib/format/money"; @@ -59,8 +60,8 @@ export function LoanPaymentPanel({ summary, spaceSlug, loanId, viewer }: Props) ); return ( - - {summary.headline.label}} /> + {summary.headline.label}}> +
      LoanMemberItemsUnits{dateHeading}Status
      @@ -180,6 +181,7 @@ export function LoanPaymentPanel({ summary, spaceSlug, loanId, viewer }: Props) ) : null} - + + ); } diff --git a/components/repairs/MoneyPanel.tsx b/components/repairs/MoneyPanel.tsx index 19309d1..73b164f 100644 --- a/components/repairs/MoneyPanel.tsx +++ b/components/repairs/MoneyPanel.tsx @@ -5,7 +5,8 @@ import type { PayoutMethod } from "@prisma/client"; import { approveRepairAmountAction, recordRepairPayoutAction } from "@/app/actions/repairs"; import { useActionRunner } from "@/components/desk/useActionRunner"; import { Button } from "@/components/ui/button"; -import { Callout, Card, CardHeader, DetailList } from "@/components/ui/display"; +import { CollapsibleCard } from "@/components/ui/collapsible"; +import { Callout, DetailList } from "@/components/ui/display"; import { Field, Input } from "@/components/ui/field"; import { Dropdown } from "@/components/ui/select"; import { PAYOUT_METHODS, PAYOUT_METHOD_LABEL } from "@/lib/finance/labels"; @@ -33,12 +34,12 @@ export function MoneyPanel({ slug, currency, repair, pay }: { slug: string; curr const [paidOn, setPaidOn] = useState(today()); const { run, pending, error } = useActionRunner(); const issueId = repair.id; - const canApprove = pay && (repair.status === "FIXED" || repair.status === "CLOSED") && repair.technician; + // Once it is paid in full there is nothing left to approve. + const canApprove = pay && (repair.status === "FIXED" || repair.status === "CLOSED") && repair.technician && money.payout !== "PAID"; const canPay = pay && money.approvedMinor !== null && left > 0 && repair.status !== "CANCELLED"; return ( - - +
      {error} : null}
      -
      + ); } diff --git a/components/repairs/RequestsPanel.tsx b/components/repairs/RequestsPanel.tsx index 22bd36c..c3d2738 100644 --- a/components/repairs/RequestsPanel.tsx +++ b/components/repairs/RequestsPanel.tsx @@ -6,7 +6,8 @@ import { assignTechnicianAction, sendRepairRequestsAction, withdrawRepairRequest import { useActionRunner } from "@/components/desk/useActionRunner"; import { Button } from "@/components/ui/button"; import { CheckboxField, FilterChip } from "@/components/ui/choice"; -import { Callout, Card, CardHeader } from "@/components/ui/display"; +import { CollapsibleCard } from "@/components/ui/collapsible"; +import { Callout } from "@/components/ui/display"; import { Field } from "@/components/ui/field"; import { Dropdown } from "@/components/ui/select"; import { CHANNEL_LABEL } from "@/lib/repairs/channels"; @@ -27,8 +28,7 @@ export function RequestsPanel({ slug, issueId, open, requests, technicians }: { const toggle = (id: string, on: boolean) => setChosen((list) => (on ? [...list, id] : list.filter((entry) => entry !== id))); return ( - - +
      {requests.length ? (
        @@ -83,6 +83,6 @@ export function RequestsPanel({ slug, issueId, open, requests, technicians }: { ) : null} {error ? {error} : null}
      -
      + ); } diff --git a/components/reports/ReportTableView.tsx b/components/reports/ReportTableView.tsx index 58d75e0..35f7b88 100644 --- a/components/reports/ReportTableView.tsx +++ b/components/reports/ReportTableView.tsx @@ -1,4 +1,5 @@ -import { EmptyState, SectionHeading } from "@/components/ui/display"; +import { CollapsibleSection } from "@/components/ui/collapsible"; +import { EmptyState } from "@/components/ui/display"; import { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table"; import { formatMoney } from "@/lib/format/money"; import type { Cell, Column, ReportTable } from "@/lib/reports/table"; @@ -10,11 +11,13 @@ function display(column: Column, value: Cell, currency: string) { return value; } -/** One report table, exactly as it is exported (numbers right-aligned, money in the space currency). */ +/** + * One report table, exactly as it is exported (numbers right-aligned, money + * in the space currency). Each folds away under its title; empty ones start folded. + */ export function ReportTableView({ table, currency, empty = "Nothing to show for this period." }: { table: ReportTable; currency: string; empty?: string }) { return ( -
      - + 0}> {table.rows.length === 0 ? ( ) : ( @@ -39,6 +42,6 @@ export function ReportTableView({ table, currency, empty = "Nothing to show for
      )} - + ); } diff --git a/components/settings/MembersTable.tsx b/components/settings/MembersTable.tsx index dbacaac..cba5d6a 100644 --- a/components/settings/MembersTable.tsx +++ b/components/settings/MembersTable.tsx @@ -58,7 +58,7 @@ export function MembersTable({ slug, actorRole, members }: { slug: string; actor return ( <> - +
      diff --git a/components/stock/MovementsTable.tsx b/components/stock/MovementsTable.tsx index 6bf7615..bf4238d 100644 --- a/components/stock/MovementsTable.tsx +++ b/components/stock/MovementsTable.tsx @@ -19,7 +19,7 @@ function sideEffects(row: MovementView) { /** The append-only stock ledger: newest first, signed changes, who and why. */ export function MovementsTable({ slug, rows, showItem = true }: { slug: string; rows: MovementView[]; showItem?: boolean }) { return ( -
      Member
      +
      diff --git a/components/stock/RepairShelf.tsx b/components/stock/RepairShelf.tsx index b5a198a..01cbae6 100644 --- a/components/stock/RepairShelf.tsx +++ b/components/stock/RepairShelf.tsx @@ -6,7 +6,8 @@ import { changeAssetStateAction } from "@/app/actions/stock"; import { useRunAction } from "@/components/catalogue/useRunAction"; import { CONDITION_LABEL } from "@/components/catalogue/words"; import { Button } from "@/components/ui/button"; -import { Card, CardHeader, EmptyState } from "@/components/ui/display"; +import { CollapsibleCard } from "@/components/ui/collapsible"; +import { EmptyState } from "@/components/ui/display"; import { ConfirmDialog } from "@/components/ui/dialog"; import { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table"; import { ResolveRepairDialog } from "./RepairDialogs"; @@ -24,8 +25,7 @@ export function RepairShelf({ slug, levels, assets, canAct }: { slug: string; le return (
      {levels.length > 0 ? ( - - +
      When
      {canAct ? : null} @@ -44,11 +44,10 @@ export function RepairShelf({ slug, levels, assets, canAct }: { slug: string; le ))}
      ItemLocationUnder repairActions
      - + ) : null} {assets.length > 0 ? ( - - + {canAct ? : null} @@ -68,7 +67,7 @@ export function RepairShelf({ slug, levels, assets, canAct }: { slug: string; le ))}
      Asset tagItemLocationConditionActions
      -
      + ) : null} {resolving ? ( +
      <button @@ -80,6 +85,48 @@ export function CollapsibleCard({ {action ? <div className="flex shrink-0 items-center gap-2">{action}</div> : null} </div> <div id={bodyId} hidden={!open} className="px-5 pb-5"> + {description ? <p className="-mt-1 mb-4 text-body-ui text-ink-muted">{description}</p> : null} + {children} + </div> + </section> + ); +} + +/** + * A page section (heading, then content with its own chrome such as a table) + * that folds away behind its heading, like CollapsibleCard without the card. + */ +export function CollapsibleSection({ + id, title, count, description, action, defaultOpen = true, titleAs: Title = "h2", className, children, +}: { + id: string; + title: string; + count?: number; + description?: ReactNode; + action?: ReactNode; + defaultOpen?: boolean; + titleAs?: "h2" | "h3"; + className?: string; + children: ReactNode; +}) { + const [open, toggle] = useRememberedOpen(`swi:dash:${id}`, defaultOpen); + const bodyId = useId(); + return ( + <section aria-label={title} className={cn("flex min-w-0 flex-col gap-3", className)}> + <div className="flex flex-wrap items-end justify-between gap-3"> + <div className="min-w-0"> + <Title> + <button type="button" onClick={toggle} aria-expanded={open} aria-controls={bodyId} className="-ml-1 flex min-h-11 items-center gap-2 rounded-lg px-1 text-left text-h4 text-ink transition-colors hover:text-accent-text"> + <ChevronDown aria-hidden className={cn("size-5 shrink-0 text-ink-muted transition-transform duration-[160ms] motion-reduce:transition-none", !open && "-rotate-90")} /> + <span className="min-w-0">{title}</span> + {count !== undefined ? <span className="min-w-6 rounded-full bg-surface px-2 text-center text-caption font-semibold leading-6 tabular-nums text-ink-secondary">{count}</span> : null} + </button> + + {description && open ?

      {description}

      : null} +
      + {open ? action : null} +
    + diff --git a/components/ui/layouts.tsx b/components/ui/layouts.tsx index 058daf1..4492d08 100644 --- a/components/ui/layouts.tsx +++ b/components/ui/layouts.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; import { cn } from "@/lib/cn"; /** Geometry only: pages pass data, permissions, navigation and state as slots. */ @@ -11,7 +12,7 @@ export function DashboardLayout({ header, stats, actions, rail, children }: Page return
    {header}{stats}{actions}
    {children}
    {rail ? : null}
    ; } export function DetailLayout({ header, back, tabs, aside, children, className }: PageSlots & { back?: { href: string; label: string }; tabs?: ReactNode; aside?: ReactNode }) { - return
    {back ? ← {back.label} : null}{header}{tabs}
    {children}
    {aside ? : null}
    ; + return
    {back ? {back.label} : null}{header}{tabs}
    {children}
    {aside ? : null}
    ; } export function SettingsLayout({ header, tabs, children }: PageSlots & { tabs?: ReactNode }) { return
    {header}{tabs}
    {children}
    ; diff --git a/components/ui/table.tsx b/components/ui/table.tsx index f2f03b7..079422d 100644 --- a/components/ui/table.tsx +++ b/components/ui/table.tsx @@ -1,11 +1,14 @@ import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react"; import { cn } from "@/lib/cn"; -/** Rounded (16px), bordered table with 48px rows that scrolls inside its own box on small screens (docs/DESIGN.md §4.8). */ -export function Table({ className, children, label, ...props }: HTMLAttributes & { label?: string }) { +/** + * Rounded (16px), bordered table with 48px rows that scrolls inside its own box on small screens (docs/DESIGN.md §4.8). + * `long` caps its height so long lists scroll inside the box with the header row kept in view. + */ +export function Table({ className, children, label, long = false, ...props }: HTMLAttributes & { label?: string; long?: boolean }) { return ( // `relative` keeps absolutely positioned cell content (sr-only labels) inside the scroll area, so a wide table never widens the page. -
    +
    {children}
    @@ -26,7 +29,7 @@ export function TR({ className, ...props }: HTMLAttributes) } export function TH({ className, ...props }: ThHTMLAttributes) { - return ; + return ; } export function TD({ className, ...props }: TdHTMLAttributes) { diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index ec57db1..f2209ea 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -115,6 +115,12 @@ only from [SpaceWorks-Design](https://spaceworks-hq.github.io/SpaceWorks-Design/ the space name is a bell icon in the same ink colour (it was the word "Inbox"); the unread count still sits beside it and the link keeps its "Notifications, N unread" name. +13. **Collapsible everywhere it reduces clutter** (2026-10-25). Secondary or + empty sections on busy pages fold (CollapsibleCard for cards, + CollapsibleSection for headed tables), keyed by the kind of section so a + choice applies on every page that shows it; the primary content and + forms in progress stay open. Long tables cap their height and keep a + sticky header. ## Defaults the owner may revisit diff --git a/frontend/contracts.d.ts b/frontend/contracts.d.ts index d56c0aa..c879430 100644 --- a/frontend/contracts.d.ts +++ b/frontend/contracts.d.ts @@ -1,5 +1,5 @@ // Generated transport DTOs. No backend imports. -// Source SHA256: f61c1be39fb7f431fbec174d9a4aa49046a70f051f5942c5eb3b7e515bae1d5f +// Source SHA256: 9a52c68c63247b8c1f6bb22967068fa4a9865faa139520da2654a342d4961290 export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; export type Views = { "account-page:loadAccountPage": { "user": { "email": string; "name": null | string; "phone": null | string; "mfaEnabledAt": null | Date }; "spaces": Array<{ "id": string; "slug": string; "name": string; "role": "MANAGER" | "STAFF" | "MEMBER" | "TECHNICIAN" }>; "remaining": number; "hasPassword": boolean }; diff --git a/frontend/pages.json b/frontend/pages.json index 5f2bc05..8bc95c9 100644 --- a/frontend/pages.json +++ b/frontend/pages.json @@ -1,7 +1,7 @@ [ { "file": "app/account/page.tsx", - "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport type { Metadata } from \"next\";\nimport { SimpleShell } from \"@/components/chrome/SimpleShell\";\nimport { AppearanceCard } from \"@/components/account/AppearanceCard\";\nimport { MfaCard } from \"@/components/account/MfaCard\";\nimport { PasswordForm } from \"@/components/account/PasswordForm\";\nimport { ProfileForm } from \"@/components/account/ProfileForm\";\nimport { SessionsCard } from \"@/components/account/SessionsCard\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, PageHeader, SettingsRow } from \"@/components/ui/display\";\nimport { prisma } from \"@/lib/db\";\nimport { remainingRecoveryCodes } from \"@/lib/mfa\";\nimport { requireAuth } from \"@/lib/permissions\";\nimport { roleLabel } from \"@/lib/roles\";\nimport { listUserSpaces } from \"@/lib/spaces\";\n\nexport const metadata: Metadata = { title: \"Account\" };\n\nexport default async function AccountPage() {\nconst { user, spaces, remaining, hasPassword } = await invokeBackend(\"view\", \"account-page:loadAccountPage\", []);\n\nreturn (\n \n
    \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n {spaces.length === 0 ? (\n

    You aren't in any space yet.

    \n ) : (\n
      \n {spaces.map((space) => (\n
    • \n {space.name}\n {roleLabel(space.role)}\n
    • \n ))}\n
    \n )}\n
    \n
    \n
    \n
    \n );\n}\n" + "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport type { Metadata } from \"next\";\nimport { SimpleShell } from \"@/components/chrome/SimpleShell\";\nimport { AppearanceCard } from \"@/components/account/AppearanceCard\";\nimport { MfaCard } from \"@/components/account/MfaCard\";\nimport { PasswordForm } from \"@/components/account/PasswordForm\";\nimport { ProfileForm } from \"@/components/account/ProfileForm\";\nimport { SessionsCard } from \"@/components/account/SessionsCard\";\nimport { Button } from \"@/components/ui/button\";\nimport { CollapsibleCard } from \"@/components/ui/collapsible\";\nimport { PageHeader, SettingsRow } from \"@/components/ui/display\";\nimport { prisma } from \"@/lib/db\";\nimport { remainingRecoveryCodes } from \"@/lib/mfa\";\nimport { requireAuth } from \"@/lib/permissions\";\nimport { roleLabel } from \"@/lib/roles\";\nimport { listUserSpaces } from \"@/lib/spaces\";\n\nexport const metadata: Metadata = { title: \"Account\" };\n\nexport default async function AccountPage() {\nconst { user, spaces, remaining, hasPassword } = await invokeBackend(\"view\", \"account-page:loadAccountPage\", []);\n\nreturn (\n \n
    \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n {spaces.length === 0 ? (\n

    You aren't in any space yet.

    \n ) : (\n
      \n {spaces.map((space) => (\n
    • \n {space.name}\n {roleLabel(space.role)}\n
    • \n ))}\n
    \n )}\n
    \n
    \n
    \n
    \n );\n}\n" }, { "file": "app/admin-mfa/page.tsx", @@ -69,7 +69,7 @@ }, { "file": "app/s/[spaceSlug]/assets/[assetId]/page.tsx", - "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport type { Metadata } from \"next\";\nimport Link from \"next/link\";\nimport { notFound } from \"next/navigation\";\nimport { AssetActions } from \"@/components/catalogue/AssetActions\";\nimport { AssetLoans, AssetTransfers } from \"@/components/catalogue/AssetHistory\";\nimport { QrPlate } from \"@/components/catalogue/QrPlate\";\nimport { CONDITION_LABEL } from \"@/components/catalogue/words\";\nimport { AssetStatusChip } from \"@/components/inventory/StatusChips\";\nimport { MovementsTable } from \"@/components/stock/MovementsTable\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardHeader, DetailList, PageHeader } from \"@/components/ui/display\";\nimport { DetailLayout } from \"@/components/ui/layouts\";\nimport { loanRef, transferRef } from \"@/lib/catalogue/references\";\nimport { loadMovements, locationOptions, qrSvg } from \"@/lib/catalogue/views\";\nimport { prisma } from \"@/lib/db\";\nimport { formatDate, formatRelativeTime } from \"@/lib/format/datetime\";\nimport { formatMoney } from \"@/lib/format/money\";\nimport { canAdjustStock, canManageCatalogue, requireSpace } from \"@/lib/permissions\";\n\nexport const metadata: Metadata = { title: \"Asset\" };\n\nexport default async function AssetPage({ params }: PageProps<\"/s/[spaceSlug]/assets/[assetId]\">) {const { spaceSlug, assetId } = await params;\nconst { slug, locations, qr, loans, transfers, movements, facts, details, header, labelHref, canAdjust, canEdit, locationList } = await invokeBackend(\"view\", \"s-spaceSlug-assets-assetId-page:loadAssetPage\", [{ params, }]);\n\nreturn (\n {header.itemName}}\n title={header.tag}\n actions={}\n />\n }\n aside={\n
    \n \n \n \n \n \n \n \n \n {header.notes ?

    {header.notes}

    : null}\n
    \n
    \n }\n >\n
    \n
    \n \n Full ledger} />\n {movements.length === 0 ?

    No movements yet.

    : }\n
    \n \n \n \n \n \n \n \n \n
    \n \n );\n}\n" + "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport type { Metadata } from \"next\";\nimport Link from \"next/link\";\nimport { notFound } from \"next/navigation\";\nimport { AssetActions } from \"@/components/catalogue/AssetActions\";\nimport { AssetLoans, AssetTransfers } from \"@/components/catalogue/AssetHistory\";\nimport { QrPlate } from \"@/components/catalogue/QrPlate\";\nimport { CONDITION_LABEL } from \"@/components/catalogue/words\";\nimport { AssetStatusChip } from \"@/components/inventory/StatusChips\";\nimport { MovementsTable } from \"@/components/stock/MovementsTable\";\nimport { Button } from \"@/components/ui/button\";\nimport { CollapsibleCard } from \"@/components/ui/collapsible\";\nimport { DetailList, PageHeader } from \"@/components/ui/display\";\nimport { DetailLayout } from \"@/components/ui/layouts\";\nimport { loanRef, transferRef } from \"@/lib/catalogue/references\";\nimport { loadMovements, locationOptions, qrSvg } from \"@/lib/catalogue/views\";\nimport { prisma } from \"@/lib/db\";\nimport { formatDate, formatRelativeTime } from \"@/lib/format/datetime\";\nimport { formatMoney } from \"@/lib/format/money\";\nimport { canAdjustStock, canManageCatalogue, requireSpace } from \"@/lib/permissions\";\n\nexport const metadata: Metadata = { title: \"Asset\" };\n\nexport default async function AssetPage({ params }: PageProps<\"/s/[spaceSlug]/assets/[assetId]\">) {const { spaceSlug, assetId } = await params;\nconst { slug, locations, qr, loans, transfers, movements, facts, details, header, labelHref, canAdjust, canEdit, locationList } = await invokeBackend(\"view\", \"s-spaceSlug-assets-assetId-page:loadAssetPage\", [{ params, }]);\n\nreturn (\n {header.itemName}}\n title={header.tag}\n actions={}\n />\n }\n aside={\n
    \n \n \n \n \n \n \n {header.notes ?

    {header.notes}

    : null}\n
    \n
    \n }\n >\n
    \n
    \n Full ledger} count={movements.length} defaultOpen={movements.length > 0}>\n {movements.length === 0 ?

    No movements yet.

    : }\n
    \n 0}>\n \n \n 0}>\n \n \n
    \n \n );\n}\n" }, { "file": "app/s/[spaceSlug]/assets/page.tsx", @@ -85,11 +85,11 @@ }, { "file": "app/s/[spaceSlug]/catalogue/self-checkout/page.tsx", - "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport Link from \"next/link\";\nimport { SelfCheckoutForm } from \"@/components/borrow/SelfCheckoutForm\";\nimport { buttonClasses } from \"@/components/ui/button\";\nimport { EmptyState, PageHeader } from \"@/components/ui/display\";\nimport { DetailLayout } from \"@/components/ui/layouts\";\nimport { prisma } from \"@/lib/db\";\nimport { selfCheckoutCatalog } from \"@/lib/desk/catalogue\";\nimport { selfCheckoutOff } from \"@/lib/desk/selfCheckoutOff\";\nimport { checkBorrowingEligibility } from \"@/lib/lending/access\";\nimport { requireSpace } from \"@/lib/permissions\";\n\nexport default async function SelfCheckoutPage({ params }: PageProps<\"/s/[spaceSlug]/catalogue/self-checkout\">) {const { spaceSlug } = await params;\nconst { off, catalog, blocked, slug, minPhotos, items } = await invokeBackend(\"view\", \"s-spaceSlug-catalogue-self-checkout-page:loadSelfCheckoutPage\", [{ params, }]);\n\nreturn (\n }\n >\n {off ? (\n Request from the catalogue : undefined}\n >\n {off.body}\n \n ) : items.length === 0 ? (\n {(catalog?.paymentExcluded ?? 0) > 0 ? \"These items have a deposit or fee and pay later is switched off. Borrow them at the desk.\" : \"No items are set up for self-checkout. Request them from the catalogue instead.\"}\n ) : (\n \n )}\n \n );\n}\n" + "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport Link from \"next/link\";\nimport { SelfCheckoutForm } from \"@/components/borrow/SelfCheckoutForm\";\nimport { buttonClasses } from \"@/components/ui/button\";\nimport { EmptyState, PageHeader } from \"@/components/ui/display\";\nimport { DetailLayout } from \"@/components/ui/layouts\";\nimport { prisma } from \"@/lib/db\";\nimport { selfCheckoutCatalog } from \"@/lib/desk/catalogue\";\nimport { selfCheckoutOff } from \"@/lib/desk/selfCheckoutOff\";\nimport { checkBorrowingEligibility } from \"@/lib/lending/access\";\nimport { requireSpace } from \"@/lib/permissions\";\n\nexport default async function SelfCheckoutPage({ params }: PageProps<\"/s/[spaceSlug]/catalogue/self-checkout\">) {const { spaceSlug } = await params;\nconst { off, catalog, blocked, slug, minPhotos, items } = await invokeBackend(\"view\", \"s-spaceSlug-catalogue-self-checkout-page:loadSelfCheckoutPage\", [{ params, }]);\n\nreturn (\n }\n >\n {off ? (\n Request from the catalogue : undefined}\n >\n {off.body}\n \n ) : items.length === 0 ? (\n {(catalog?.paymentExcluded ?? 0) > 0 ? \"These items have a deposit or fee and pay later is switched off. Borrow them at the desk.\" : \"No items are set up for self-checkout. Request them from the catalogue instead.\"}\n ) : (\n \n )}\n \n );\n}\n" }, { "file": "app/s/[spaceSlug]/counts/[countId]/page.tsx", - "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport { notFound } from \"next/navigation\";\nimport { AddLineDialog } from \"@/components/counts/AddLineDialog\";\nimport { CountActions } from \"@/components/counts/CountActions\";\nimport { CountSheet } from \"@/components/counts/CountSheet\";\nimport { ScanCountCard } from \"@/components/counts/ScanCountCard\";\nimport { VarianceTable } from \"@/components/counts/VarianceTable\";\nimport { CountStatusChip } from \"@/components/inventory/StatusChips\";\nimport { Callout, Card, DetailList, PageHeader } from \"@/components/ui/display\";\nimport { DetailLayout } from \"@/components/ui/layouts\";\nimport { countFormOptions } from \"@/lib/desk/counts\";\nimport { countDetail } from \"@/lib/desk/countsDetail\";\nimport { groupByLocation } from \"@/lib/desk/countsLogic\";\nimport { formatDayTime } from \"@/lib/format/datetime\";\nimport { Patch } from \"@/components/brand/Patch\";\nimport { perfectCount } from \"@/lib/desk/eggs\";\nimport { canCountStock, canManageCounts, requireSpace } from \"@/lib/permissions\";\n\nfunction notice(count: { status: string; blind: boolean; threshold: number | null; fourEyes: { blocked: boolean }; totals: { stale: number } }, canManage: boolean) {\n if (count.fourEyes.blocked && canManage) return { tone: \"warning\" as const, title: \"Someone else must post this count\", text: `You started or counted it, and some differences are larger than ${count.threshold ?? 0}. Ask another manager who didn't count it to post.` };\n if (count.totals.stale > 0 && (count.status === \"IN_PROGRESS\" || count.status === \"SUBMITTED\")) return { tone: \"warning\" as const, title: `Stock moved on ${count.totals.stale} ${count.totals.stale === 1 ? \"line\" : \"lines\"}`, text: \"Items were issued, received or moved since the count started. Refresh those lines and count them again before posting.\" };\n if (count.blind && count.status === \"IN_PROGRESS\") return { tone: \"info\" as const, title: \"Blind count\", text: \"Expected quantities stay hidden until the count is submitted. Count what you see.\" };\n return null;\n}\n\nexport default async function CountPage({ params }: PageProps<\"/s/[spaceSlug]/counts/[countId]\">) {const { spaceSlug, countId } = await params;\nconst { count, canCount, canManage, editable, locations, items, slug, groups, showVariances, callout, facts, header, lines, perfect } = await invokeBackend(\"view\", \"s-spaceSlug-counts-countId-page:loadCountPage\", [{ params, }]);\n\nreturn (\n } />}\n aside={}\n >\n
    \n {callout ? {callout.text} : null}\n \n {perfect ? (\n \n \n

    Every line matched

    \n

    Nothing to adjust: what’s on the shelves is what the books say.

    \n
    \n ) : null}\n {showVariances ? : null}\n {editable ? : null}\n \n {editable ?
    : null}\n
    \n \n );\n}\n" + "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport { notFound } from \"next/navigation\";\nimport { AddLineDialog } from \"@/components/counts/AddLineDialog\";\nimport { CountActions } from \"@/components/counts/CountActions\";\nimport { CountSheet } from \"@/components/counts/CountSheet\";\nimport { ScanCountCard } from \"@/components/counts/ScanCountCard\";\nimport { VarianceTable } from \"@/components/counts/VarianceTable\";\nimport { CountStatusChip } from \"@/components/inventory/StatusChips\";\nimport { Callout, Card, DetailList, PageHeader } from \"@/components/ui/display\";\nimport { DetailLayout } from \"@/components/ui/layouts\";\nimport { countFormOptions } from \"@/lib/desk/counts\";\nimport { countDetail } from \"@/lib/desk/countsDetail\";\nimport { groupByLocation } from \"@/lib/desk/countsLogic\";\nimport { formatDayTime } from \"@/lib/format/datetime\";\nimport { Patch } from \"@/components/brand/Patch\";\nimport { perfectCount } from \"@/lib/desk/eggs\";\nimport { canCountStock, canManageCounts, requireSpace } from \"@/lib/permissions\";\n\nfunction notice(count: { status: string; blind: boolean; threshold: number | null; fourEyes: { blocked: boolean }; totals: { stale: number } }, canManage: boolean) {\n if (count.fourEyes.blocked && canManage) return { tone: \"warning\" as const, title: \"Someone else must post this count\", text: `You started or counted it, and some differences are larger than ${count.threshold ?? 0}. Ask another manager who didn't count it to post.` };\n if (count.totals.stale > 0 && (count.status === \"IN_PROGRESS\" || count.status === \"SUBMITTED\")) return { tone: \"warning\" as const, title: `Stock moved on ${count.totals.stale} ${count.totals.stale === 1 ? \"line\" : \"lines\"}`, text: \"Items were issued, received or moved since the count started. Refresh those lines and count them again before posting.\" };\n if (count.blind && count.status === \"IN_PROGRESS\") return { tone: \"info\" as const, title: \"Blind count\", text: \"Expected quantities stay hidden until the count is submitted. Count what you see.\" };\n return null;\n}\n\nexport default async function CountPage({ params }: PageProps<\"/s/[spaceSlug]/counts/[countId]\">) {const { spaceSlug, countId } = await params;\nconst { count, canCount, canManage, editable, locations, items, slug, groups, showVariances, callout, facts, header, lines, perfect } = await invokeBackend(\"view\", \"s-spaceSlug-counts-countId-page:loadCountPage\", [{ params, }]);\n\nreturn (\n } />}\n aside={}\n >\n
    \n {callout ? {callout.text} : null}\n \n {perfect ? (\n \n \n

    Every line matched

    \n

    Nothing to adjust: what’s on the shelves is what the books say.

    \n
    \n ) : null}\n {showVariances ? : null}\n {editable ? : null}\n \n {editable ?
    : null}\n
    \n \n );\n}\n" }, { "file": "app/s/[spaceSlug]/counts/new/page.tsx", @@ -113,7 +113,7 @@ }, { "file": "app/s/[spaceSlug]/items/[itemId]/page.tsx", - "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport type { Metadata } from \"next\";\nimport Link from \"next/link\";\nimport { notFound } from \"next/navigation\";\nimport { AddAssetsForm } from \"@/components/catalogue/AddAssetsForm\";\nimport { ItemDetailActions } from \"@/components/catalogue/ItemDetailActions\";\nimport { ItemPhotos } from \"@/components/catalogue/ItemPhotos\";\nimport { ItemAssetsTable, StockByLocation } from \"@/components/catalogue/ItemStockTables\";\nimport { QrPlate } from \"@/components/catalogue/QrPlate\";\nimport { RENTAL_MODE_LABEL } from \"@/components/catalogue/words\";\nimport { MovementsTable } from \"@/components/stock/MovementsTable\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardHeader, Chip, DetailList, PageHeader, StatTile } from \"@/components/ui/display\";\nimport { DetailLayout } from \"@/components/ui/layouts\";\nimport { itemImageUrl } from \"@/lib/catalogue/images\";\nimport { loadMovements, locationOptions, qrSvg } from \"@/lib/catalogue/views\";\nimport { prisma } from \"@/lib/db\";\nimport { formatRelativeTime } from \"@/lib/format/datetime\";\nimport { currencyMinorDigits, formatMoney } from \"@/lib/format/money\";\nimport { availabilityFor, availabilityOf, levelsForItem } from \"@/lib/inventory/availability\";\nimport { canAdjustStock, canManageCatalogue, requireSpace } from \"@/lib/permissions\";\n\nexport const metadata: Metadata = { title: \"Item\" };\n\nfunction money(minor: number | null, currency: string, fallback: string) {\n return minor === null ? fallback : formatMoney(minor, currency);\n}\n\nexport default async function ItemPage({ params }: PageProps<\"/s/[spaceSlug]/items/[itemId]\">) {const { spaceSlug, itemId } = await params;\nconst { slug, currency, minorDigits, levelRows, levels, assets, movements, locations, qr, canEdit, canAdjust, unit, photos, low, archived, facts, lending, stats, value, header, id, labelHref } = await invokeBackend(\"view\", \"s-spaceSlug-items-itemId-page:loadItemPage\", [{ params, }]);\n\nreturn (\n }\n />\n }\n aside={\n
    \n \n \n \n \n \n \n \n \n \n {lending.length > 0 ? (\n \n \n \n \n ) : null}\n
    \n }\n >\n
    \n
    \n {header.tracking === \"SERIALIZED\" ? \"Serialized\" : \"By quantity\"}\n {header.lendable ? Lendable : Not lendable}\n {header.consumable ? Consumable : null}\n {low ? Low stock : null}\n {archived ? Archived : null}\n
    \n
    \n {stats.map((stat) => )}\n \n
    \n \n \n \n \n \n {header.tracking === \"SERIALIZED\" ? (\n \n \n \n {canEdit && !archived ? (\n
    \n

    Register units

    \n \n
    \n ) : null}\n
    \n ) : null}\n \n Full ledger} />\n {movements.length === 0 ?

    No stock movements yet.

    : }\n
    \n {header.notes ? (\n \n \n

    {header.notes}

    \n
    \n ) : null}\n
    \n \n );\n}\n" + "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport type { Metadata } from \"next\";\nimport Link from \"next/link\";\nimport { notFound } from \"next/navigation\";\nimport { AddAssetsForm } from \"@/components/catalogue/AddAssetsForm\";\nimport { ItemDetailActions } from \"@/components/catalogue/ItemDetailActions\";\nimport { ItemPhotos } from \"@/components/catalogue/ItemPhotos\";\nimport { ItemAssetsTable, StockByLocation } from \"@/components/catalogue/ItemStockTables\";\nimport { QrPlate } from \"@/components/catalogue/QrPlate\";\nimport { RENTAL_MODE_LABEL } from \"@/components/catalogue/words\";\nimport { MovementsTable } from \"@/components/stock/MovementsTable\";\nimport { Button } from \"@/components/ui/button\";\nimport { CollapsibleCard } from \"@/components/ui/collapsible\";\nimport { Chip, DetailList, PageHeader, StatTile } from \"@/components/ui/display\";\nimport { DetailLayout } from \"@/components/ui/layouts\";\nimport { itemImageUrl } from \"@/lib/catalogue/images\";\nimport { loadMovements, locationOptions, qrSvg } from \"@/lib/catalogue/views\";\nimport { prisma } from \"@/lib/db\";\nimport { formatRelativeTime } from \"@/lib/format/datetime\";\nimport { currencyMinorDigits, formatMoney } from \"@/lib/format/money\";\nimport { availabilityFor, availabilityOf, levelsForItem } from \"@/lib/inventory/availability\";\nimport { canAdjustStock, canManageCatalogue, requireSpace } from \"@/lib/permissions\";\n\nexport const metadata: Metadata = { title: \"Item\" };\n\nfunction money(minor: number | null, currency: string, fallback: string) {\n return minor === null ? fallback : formatMoney(minor, currency);\n}\n\nexport default async function ItemPage({ params }: PageProps<\"/s/[spaceSlug]/items/[itemId]\">) {const { spaceSlug, itemId } = await params;\nconst { slug, currency, minorDigits, levelRows, levels, assets, movements, locations, qr, canEdit, canAdjust, unit, photos, low, archived, facts, lending, stats, value, header, id, labelHref } = await invokeBackend(\"view\", \"s-spaceSlug-items-itemId-page:loadItemPage\", [{ params, }]);\n\nreturn (\n }\n />\n }\n aside={\n
    \n \n \n \n \n \n \n \n {lending.length > 0 ? (\n \n \n \n ) : null}\n
    \n }\n >\n
    \n
    \n {header.tracking === \"SERIALIZED\" ? \"Serialized\" : \"By quantity\"}\n {header.lendable ? Lendable : Not lendable}\n {header.consumable ? Consumable : null}\n {low ? Low stock : null}\n {archived ? Archived : null}\n
    \n
    \n {stats.map((stat) => )}\n \n
    \n \n \n \n \n {header.tracking === \"SERIALIZED\" ? (\n \n \n {canEdit && !archived ? (\n
    \n

    Register units

    \n \n
    \n ) : null}\n
    \n ) : null}\n 0} action={}>\n {movements.length === 0 ?

    No stock movements yet.

    : }\n
    \n {header.notes ? (\n \n

    {header.notes}

    \n
    \n ) : null}\n
    \n \n );\n}\n" }, { "file": "app/s/[spaceSlug]/items/new/page.tsx", @@ -137,7 +137,7 @@ }, { "file": "app/s/[spaceSlug]/loans/[loanId]/page.tsx", - "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport { notFound } from \"next/navigation\";\nimport { EvidenceGallery } from \"@/components/desk/EvidenceGallery\";\nimport { HandoverDone } from \"@/components/desk/HandoverDone\";\nimport { Timeline } from \"@/components/desk/Timeline\";\nimport { LoanStatusChip } from \"@/components/inventory/StatusChips\";\nimport { ApproveCard } from \"@/components/loans/ApproveCard\";\nimport { IncidentList } from \"@/components/loans/IncidentList\";\nimport { IssueFlow } from \"@/components/loans/IssueFlow\";\nimport { LoanFacts } from \"@/components/loans/LoanFacts\";\nimport { LoanLinesTable } from \"@/components/loans/LoanLinesTable\";\nimport { LoanSideActions } from \"@/components/loans/LoanSideActions\";\nimport { ReturnFlow } from \"@/components/loans/ReturnFlow\";\nimport { LoanPaymentPanel } from \"@/components/payments/LoanPaymentPanel\";\nimport { Callout, PageHeader } from \"@/components/ui/display\";\nimport { DetailLayout } from \"@/components/ui/layouts\";\nimport { loanRef } from \"@/lib/catalogue/references\";\nimport { prisma } from \"@/lib/db\";\nimport { dateInputValue } from \"@/lib/desk/dates\";\nimport { doneFor } from \"@/lib/desk/stepper\";\nimport { trotsHome } from \"@/lib/desk/eggs\";\nimport { evidenceFor } from \"@/lib/desk/evidence\";\nimport { issueScanCatalog, loanDetail, loanTimeline } from \"@/lib/desk/loansDetail\";\nimport { incidentViews, lineRows, loanFacts } from \"@/lib/desk/loansView\";\nimport { locationOptions } from \"@/lib/desk/locations\";\nimport { formatDayTime, formatRelativeTime } from \"@/lib/format/datetime\";\nimport { availabilityFor, availabilityOf } from \"@/lib/inventory/availability\";\nimport { checkBorrowingEligibility } from \"@/lib/lending/access\";\nimport { isOverdue } from \"@/lib/lending/policy\";\nimport { renewalRefusal } from \"@/lib/lending/renewalPolicy\";\nimport { renewalFacts } from \"@/lib/lending/renewalReadiness\";\nimport { issuePaymentReady } from \"@/lib/payments/readiness\";\nimport { getLoanPaymentView } from \"@/lib/payments/view\";\nimport { canApproveLoans, canIssueLoans, canResolveIncidents, requireSpace } from \"@/lib/permissions\";\n\nexport default async function LoanPage({ params, searchParams }: PageProps<\"/s/[spaceSlug]/loans/[loanId]\">) {const { spaceSlug, loanId } = await params;\nconst query = await searchParams;\nconst { loan, slug, reference, status, done, overdue, approver, desk, resolver, active, payment, locations, eligibility, eligibilityProblem, approveLines, catalog, issueLines, returnLines, container, lines, incidents, facts, timeline, evidence, sideActions, minPhotos, requireRemarks, requireContainer, paymentReady, returnKey, closedNote } = await invokeBackend(\"view\", \"s-spaceSlug-loans-loanId-page:loadLoanPage\", [{ params, searchParams }]);\n\nreturn (\n {reference}} description={`${loan.borrower.name} · ${facts.summary}`} />}\n aside={\n
    \n \n \n {payment ? : null}\n
    \n }\n >\n
    \n {done ? : null}\n {closedNote ? {closedNote} : null}\n {status === \"REQUESTED\" && approver ? : null}\n {status === \"APPROVED\" && desk && catalog ? (\n \n ) : null}\n {active && desk ? : null}\n \n \n \n \n
    \n \n );\n}\n" + "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport { notFound } from \"next/navigation\";\nimport { EvidenceGallery } from \"@/components/desk/EvidenceGallery\";\nimport { HandoverDone } from \"@/components/desk/HandoverDone\";\nimport { Timeline } from \"@/components/desk/Timeline\";\nimport { LoanStatusChip } from \"@/components/inventory/StatusChips\";\nimport { ApproveCard } from \"@/components/loans/ApproveCard\";\nimport { IncidentList } from \"@/components/loans/IncidentList\";\nimport { IssueFlow } from \"@/components/loans/IssueFlow\";\nimport { LoanFacts } from \"@/components/loans/LoanFacts\";\nimport { LoanLinesTable } from \"@/components/loans/LoanLinesTable\";\nimport { LoanSideActions } from \"@/components/loans/LoanSideActions\";\nimport { ReturnFlow } from \"@/components/loans/ReturnFlow\";\nimport { LoanPaymentPanel } from \"@/components/payments/LoanPaymentPanel\";\nimport { Callout, PageHeader } from \"@/components/ui/display\";\nimport { DetailLayout } from \"@/components/ui/layouts\";\nimport { loanRef } from \"@/lib/catalogue/references\";\nimport { prisma } from \"@/lib/db\";\nimport { dateInputValue } from \"@/lib/desk/dates\";\nimport { doneFor } from \"@/lib/desk/stepper\";\nimport { trotsHome } from \"@/lib/desk/eggs\";\nimport { evidenceFor } from \"@/lib/desk/evidence\";\nimport { issueScanCatalog, loanDetail, loanTimeline } from \"@/lib/desk/loansDetail\";\nimport { incidentViews, lineRows, loanFacts } from \"@/lib/desk/loansView\";\nimport { locationOptions } from \"@/lib/desk/locations\";\nimport { formatDayTime, formatRelativeTime } from \"@/lib/format/datetime\";\nimport { availabilityFor, availabilityOf } from \"@/lib/inventory/availability\";\nimport { checkBorrowingEligibility } from \"@/lib/lending/access\";\nimport { isOverdue } from \"@/lib/lending/policy\";\nimport { renewalRefusal } from \"@/lib/lending/renewalPolicy\";\nimport { renewalFacts } from \"@/lib/lending/renewalReadiness\";\nimport { issuePaymentReady } from \"@/lib/payments/readiness\";\nimport { getLoanPaymentView } from \"@/lib/payments/view\";\nimport { canApproveLoans, canIssueLoans, canResolveIncidents, requireSpace } from \"@/lib/permissions\";\n\nexport default async function LoanPage({ params, searchParams }: PageProps<\"/s/[spaceSlug]/loans/[loanId]\">) {const { spaceSlug, loanId } = await params;\nconst query = await searchParams;\nconst { loan, slug, reference, status, done, overdue, approver, desk, resolver, active, payment, locations, eligibility, eligibilityProblem, approveLines, catalog, issueLines, returnLines, container, lines, incidents, facts, timeline, evidence, sideActions, minPhotos, requireRemarks, requireContainer, paymentReady, returnKey, closedNote } = await invokeBackend(\"view\", \"s-spaceSlug-loans-loanId-page:loadLoanPage\", [{ params, searchParams }]);\n\nreturn (\n {reference}} description={`${loan.borrower.name} · ${facts.summary}`} />}\n aside={\n
    \n \n \n {payment ? : null}\n
    \n }\n >\n
    \n {done ? : null}\n {closedNote ? {closedNote} : null}\n {status === \"REQUESTED\" && approver ? : null}\n {status === \"APPROVED\" && desk && catalog ? (\n \n ) : null}\n {active && desk ? : null}\n \n \n \n \n
    \n \n );\n}\n" }, { "file": "app/s/[spaceSlug]/loans/page.tsx", @@ -149,15 +149,15 @@ }, { "file": "app/s/[spaceSlug]/members/page.tsx", - "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport Link from \"next/link\";\nimport { InvitationsPanel } from \"@/components/settings/InvitationsPanel\";\nimport { MembersTable } from \"@/components/settings/MembersTable\";\nimport { Button } from \"@/components/ui/button\";\nimport { PageHeader, SectionHeading } from \"@/components/ui/display\";\nimport { Dropdown, Field, Input } from \"@/components/ui/field\";\nimport { ListLayout } from \"@/components/ui/layouts\";\nimport { formatDate, formatRelativeTime } from \"@/lib/format/datetime\";\nimport { requireSpace } from \"@/lib/permissions\";\nimport { assignableRoles, canManageMembers, isSpaceRole, roleLabel, SPACE_ROLES } from \"@/lib/roles\";\nimport { listMembersForAdmin, listOpenInvitations } from \"@/lib/setup/members\";\n\nexport const metadata = { title: \"Members\" };\n\nfunction one(value: string | string[] | undefined) {\n return typeof value === \"string\" ? value : \"\";\n}\n\nexport default async function MembersPage({ params, searchParams }: PageProps<\"/s/[spaceSlug]/members\">) {const { spaceSlug } = await params;\nconst query = await searchParams;\nconst { space, membership, q, roleFilter, isAdmin, members, invitations, inviteRoles, filtered } = await invokeBackend(\"view\", \"s-spaceSlug-members-page:loadMembersPage\", [{ params, searchParams }]);\n\nreturn (\n \n }\n toolbar={\n
    \n \n \n \n \n \n \n {SPACE_ROLES.map((role) => )}\n \n \n \n {filtered ? : null}\n
    \n }\n >\n \n {isAdmin ? (\n
    \n \n \n
    \n ) : null}\n \n );\n}\n" + "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport Link from \"next/link\";\nimport { InvitationsPanel } from \"@/components/settings/InvitationsPanel\";\nimport { MembersTable } from \"@/components/settings/MembersTable\";\nimport { Button } from \"@/components/ui/button\";\nimport { CollapsibleSection } from \"@/components/ui/collapsible\";\nimport { PageHeader } from \"@/components/ui/display\";\nimport { Dropdown, Field, Input } from \"@/components/ui/field\";\nimport { ListLayout } from \"@/components/ui/layouts\";\nimport { formatDate, formatRelativeTime } from \"@/lib/format/datetime\";\nimport { requireSpace } from \"@/lib/permissions\";\nimport { assignableRoles, canManageMembers, isSpaceRole, roleLabel, SPACE_ROLES } from \"@/lib/roles\";\nimport { listMembersForAdmin, listOpenInvitations } from \"@/lib/setup/members\";\n\nexport const metadata = { title: \"Members\" };\n\nfunction one(value: string | string[] | undefined) {\n return typeof value === \"string\" ? value : \"\";\n}\n\nexport default async function MembersPage({ params, searchParams }: PageProps<\"/s/[spaceSlug]/members\">) {const { spaceSlug } = await params;\nconst query = await searchParams;\nconst { space, membership, q, roleFilter, isAdmin, members, invitations, inviteRoles, filtered } = await invokeBackend(\"view\", \"s-spaceSlug-members-page:loadMembersPage\", [{ params, searchParams }]);\n\nreturn (\n \n }\n toolbar={\n
    \n \n \n \n \n \n \n {SPACE_ROLES.map((role) => )}\n \n \n \n {filtered ? : null}\n
    \n }\n >\n \n {isAdmin ? (\n \n \n \n ) : null}\n \n );\n}\n" }, { "file": "app/s/[spaceSlug]/my-loans/[loanId]/page.tsx", - "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport { notFound } from \"next/navigation\";\nimport { EvidenceGallery } from \"@/components/desk/EvidenceGallery\";\nimport { LoanStatusChip } from \"@/components/inventory/StatusChips\";\nimport { IncidentList } from \"@/components/loans/IncidentList\";\nimport { LoanFacts } from \"@/components/loans/LoanFacts\";\nimport { LoanLinesTable } from \"@/components/loans/LoanLinesTable\";\nimport { MemberLoanActions } from \"@/components/loans/MemberLoanActions\";\nimport { SelfReturnFlow } from \"@/components/loans/SelfReturnFlow\";\nimport { LoanPaymentPanel } from \"@/components/payments/LoanPaymentPanel\";\nimport { Callout, PageHeader } from \"@/components/ui/display\";\nimport { DetailLayout } from \"@/components/ui/layouts\";\nimport { evidenceFor } from \"@/lib/desk/evidence\";\nimport { memberLoanDetail } from \"@/lib/desk/memberLoanDetail\";\nimport { formatDayTime } from \"@/lib/format/datetime\";\nimport { getLoanPaymentView } from \"@/lib/payments/view\";\nimport { requireSpace } from \"@/lib/permissions\";\nimport { HandoverDone } from \"@/components/desk/HandoverDone\";\nimport { doneFor } from \"@/lib/desk/stepper\";\nimport { trotsHome } from \"@/lib/desk/eggs\";\n\nexport default async function MyLoanPage({ params, searchParams }: PageProps<\"/s/[spaceSlug]/my-loans/[loanId]\">) {const { spaceSlug, loanId } = await params;\nconst query = await searchParams;\nconst { loan, slug, done, payment, evidence, minPhotos } = await invokeBackend(\"view\", \"s-spaceSlug-my-loans-loanId-page:loadMyLoanPage\", [{ params, searchParams }]);\n\nreturn (\n {loan.reference}} description={loan.facts.summary} />}\n aside={\n
    \n \n \n {payment ? : null}\n
    \n }\n >\n
    \n {done ? : null}\n {loan.nextStep ? {loan.nextStep} : null}\n {loan.closedNote ? {loan.closedNote} : null}\n {loan.selfReturn ? : null}\n \n \n {evidence.length ? : null}\n
    \n \n );\n}\n" + "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport { notFound } from \"next/navigation\";\nimport { EvidenceGallery } from \"@/components/desk/EvidenceGallery\";\nimport { LoanStatusChip } from \"@/components/inventory/StatusChips\";\nimport { IncidentList } from \"@/components/loans/IncidentList\";\nimport { LoanFacts } from \"@/components/loans/LoanFacts\";\nimport { LoanLinesTable } from \"@/components/loans/LoanLinesTable\";\nimport { MemberLoanActions } from \"@/components/loans/MemberLoanActions\";\nimport { SelfReturnFlow } from \"@/components/loans/SelfReturnFlow\";\nimport { LoanPaymentPanel } from \"@/components/payments/LoanPaymentPanel\";\nimport { Callout, PageHeader } from \"@/components/ui/display\";\nimport { DetailLayout } from \"@/components/ui/layouts\";\nimport { evidenceFor } from \"@/lib/desk/evidence\";\nimport { memberLoanDetail } from \"@/lib/desk/memberLoanDetail\";\nimport { formatDayTime } from \"@/lib/format/datetime\";\nimport { getLoanPaymentView } from \"@/lib/payments/view\";\nimport { requireSpace } from \"@/lib/permissions\";\nimport { HandoverDone } from \"@/components/desk/HandoverDone\";\nimport { doneFor } from \"@/lib/desk/stepper\";\nimport { trotsHome } from \"@/lib/desk/eggs\";\n\nexport default async function MyLoanPage({ params, searchParams }: PageProps<\"/s/[spaceSlug]/my-loans/[loanId]\">) {const { spaceSlug, loanId } = await params;\nconst query = await searchParams;\nconst { loan, slug, done, payment, evidence, minPhotos } = await invokeBackend(\"view\", \"s-spaceSlug-my-loans-loanId-page:loadMyLoanPage\", [{ params, searchParams }]);\n\nreturn (\n {loan.reference}} description={loan.facts.summary} />}\n aside={\n
    \n \n \n {payment ? : null}\n
    \n }\n >\n
    \n {done ? : null}\n {loan.nextStep ? {loan.nextStep} : null}\n {loan.closedNote ? {loan.closedNote} : null}\n {loan.selfReturn ? : null}\n \n \n {evidence.length ? : null}\n
    \n \n );\n}\n" }, { "file": "app/s/[spaceSlug]/my-loans/page.tsx", - "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport Link from \"next/link\";\nimport { MyLoansTable } from \"@/components/loans/MyLoansTable\";\nimport { buttonClasses } from \"@/components/ui/button\";\nimport { Callout, EmptyState, PageHeader, SectionHeading } from \"@/components/ui/display\";\nimport { ListLayout } from \"@/components/ui/layouts\";\nimport { loanRef } from \"@/lib/catalogue/references\";\nimport { prisma } from \"@/lib/db\";\nimport { memberLoans } from \"@/lib/desk/loansMember\";\nimport { formatDayShort, formatRelativeDay } from \"@/lib/format/datetime\";\nimport { checkBorrowingEligibility } from \"@/lib/lending/access\";\nimport { isOverdue } from \"@/lib/lending/policy\";\nimport { requireSpace } from \"@/lib/permissions\";\n\nexport default async function MyLoansPage({ params }: PageProps<\"/s/[spaceSlug]/my-loans\">) {const { spaceSlug } = await params;\nconst { blocked, rows, open, past, slug, lendingOn } = await invokeBackend(\"view\", \"s-spaceSlug-my-loans-page:loadMyLoansPage\", [{ params, }]);\n\nreturn (\n Borrow something : undefined} />}\n >\n {blocked ? {blocked} : null}\n {rows.length === 0 ? (\n Open the catalogue : undefined}>Borrow a tool from the catalogue and it shows up here.\n ) : (\n
    \n
    \n \n {open.length ? : Nothing out right now.}\n
    \n {past.length ? (\n
    \n \n \n
    \n ) : null}\n
    \n )}\n \n );\n}\n" + "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport Link from \"next/link\";\nimport { MyLoansTable } from \"@/components/loans/MyLoansTable\";\nimport { buttonClasses } from \"@/components/ui/button\";\nimport { CollapsibleSection } from \"@/components/ui/collapsible\";\nimport { Callout, EmptyState, PageHeader } from \"@/components/ui/display\";\nimport { ListLayout } from \"@/components/ui/layouts\";\nimport { loanRef } from \"@/lib/catalogue/references\";\nimport { prisma } from \"@/lib/db\";\nimport { memberLoans } from \"@/lib/desk/loansMember\";\nimport { formatDayShort, formatRelativeDay } from \"@/lib/format/datetime\";\nimport { checkBorrowingEligibility } from \"@/lib/lending/access\";\nimport { isOverdue } from \"@/lib/lending/policy\";\nimport { requireSpace } from \"@/lib/permissions\";\n\nexport default async function MyLoansPage({ params }: PageProps<\"/s/[spaceSlug]/my-loans\">) {const { spaceSlug } = await params;\nconst { blocked, rows, open, past, slug, lendingOn } = await invokeBackend(\"view\", \"s-spaceSlug-my-loans-page:loadMyLoansPage\", [{ params, }]);\n\nreturn (\n Borrow something : undefined} />}\n >\n {blocked ? {blocked} : null}\n {rows.length === 0 ? (\n Open the catalogue : undefined}>Borrow a tool from the catalogue and it shows up here.\n ) : (\n
    \n \n {open.length ? : Nothing out right now.}\n \n {past.length ? (\n \n \n \n ) : null}\n
    \n )}\n \n );\n}\n" }, { "file": "app/s/[spaceSlug]/notifications/page.tsx", @@ -177,7 +177,7 @@ }, { "file": "app/s/[spaceSlug]/repairs/[issueId]/page.tsx", - "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport type { Metadata } from \"next\";\nimport Link from \"next/link\";\nimport { notFound } from \"next/navigation\";\nimport { Timeline } from \"@/components/desk/Timeline\";\nimport { RepairStatusChip, SeverityChip } from \"@/components/repairs/RepairChips\";\nimport { RequestsPanel } from \"@/components/repairs/RequestsPanel\";\nimport { MoneyPanel } from \"@/components/repairs/MoneyPanel\";\nimport { WorkPanel } from \"@/components/repairs/WorkPanel\";\nimport { StatusPanel } from \"@/components/repairs/StatusPanel\";\nimport { Callout, Card, CardHeader, DetailList, PageHeader } from \"@/components/ui/display\";\nimport { DetailLayout } from \"@/components/ui/layouts\";\nimport { prisma } from \"@/lib/db\";\nimport { requireSpace } from \"@/lib/permissions\";\nimport { repairDetail, repairViewer } from \"@/lib/repairs\";\nimport { CHANNEL_LABEL } from \"@/lib/repairs/channels\";\nimport { SEVERITY_LABEL, TIMELINE_WORDS } from \"@/lib/repairs/labels\";\n\nexport const metadata: Metadata = { title: \"Repair\" };\n\nexport default async function RepairPage({ params }: PageProps<\"/s/[spaceSlug]/repairs/[issueId]\">) {const { spaceSlug, issueId } = await params;\nconst { space, viewer, repair, technicians, slug, timeline, canWork } = await invokeBackend(\"view\", \"s-spaceSlug-repairs-issueId-page:loadRepairPage\", [{ params, }]);\nconst photoUrl = (id: string) => `/api/s/${slug}/repairs/photos/${id}`;\nreturn (\n {repair.outcome === \"SCRAPPED\" ? Scrapped : null}} />}\n aside={\n
    \n {viewer.needsMfa ? Technicians need one to accept or record repairs. Open your account : null}\n \n {viewer.manage && (repair.status === \"REPORTED\" || repair.requests.length > 0) ? : null}\n {repair.money ? : null}\n
    \n }\n >\n
    \n \n \n {repair.item.name} ({repair.item.sku}) },\n ...(repair.asset ? [{ label: \"Asset\", value: {repair.asset.assetTag} }] : []),\n { label: \"Urgency\", value: SEVERITY_LABEL[repair.severity] },\n { label: \"Reported\", value: `${repair.reportedLabel}${repair.reportedBy ? ` by ${repair.reportedBy}` : \"\"}` },\n { label: \"Out of use\", value: repair.held ? `${repair.held.quantity}${repair.held.location ? ` at ${repair.held.location}` : \"\"}` : \"No\" },\n ...(repair.incidentLoanId && viewer.viewAll ? [{ label: \"From a loan\", value: Open the loan }] : []),\n { label: \"Technician\", value: repair.technician?.name ?? \"Not assigned yet\" },\n ]}\n />\n

    {repair.description}

    \n {repair.photos.length ? (\n \n ) : null}\n
    \n {repair.fix.remarks || canWork ? (\n \n \n {repair.fix.remarks ?

    {repair.fix.remarks}

    : null}\n {repair.fix.partsUsed ?

    Parts: {repair.fix.partsUsed}

    : null}\n {repair.closeNote ?

    Closing note: {repair.closeNote}

    : null}\n {canWork ? : null}\n
    \n ) : null}\n {repair.purchases.length ? (\n \n \n
      {repair.purchases.map((purchase) =>
    • {purchase.name} · {purchase.status.toLowerCase()}
    • )}
    \n
    \n ) : null}\n \n
    \n \n );\n}\n" + "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport type { Metadata } from \"next\";\nimport Link from \"next/link\";\nimport { notFound } from \"next/navigation\";\nimport { Timeline } from \"@/components/desk/Timeline\";\nimport { RepairStatusChip, SeverityChip } from \"@/components/repairs/RepairChips\";\nimport { RequestsPanel } from \"@/components/repairs/RequestsPanel\";\nimport { MoneyPanel } from \"@/components/repairs/MoneyPanel\";\nimport { WorkPanel } from \"@/components/repairs/WorkPanel\";\nimport { StatusPanel } from \"@/components/repairs/StatusPanel\";\nimport { CollapsibleCard } from \"@/components/ui/collapsible\";\nimport { Callout, DetailList, PageHeader } from \"@/components/ui/display\";\nimport { DetailLayout } from \"@/components/ui/layouts\";\nimport { prisma } from \"@/lib/db\";\nimport { requireSpace } from \"@/lib/permissions\";\nimport { repairDetail, repairViewer } from \"@/lib/repairs\";\nimport { CHANNEL_LABEL } from \"@/lib/repairs/channels\";\nimport { SEVERITY_LABEL, TIMELINE_WORDS } from \"@/lib/repairs/labels\";\n\nexport const metadata: Metadata = { title: \"Repair\" };\n\nexport default async function RepairPage({ params }: PageProps<\"/s/[spaceSlug]/repairs/[issueId]\">) {const { spaceSlug, issueId } = await params;\nconst { space, viewer, repair, technicians, slug, timeline, canWork } = await invokeBackend(\"view\", \"s-spaceSlug-repairs-issueId-page:loadRepairPage\", [{ params, }]);\nconst photoUrl = (id: string) => `/api/s/${slug}/repairs/photos/${id}`;\nreturn (\n {repair.outcome === \"SCRAPPED\" ? Scrapped : null}} />}\n aside={\n
    \n {viewer.needsMfa ? Technicians need one to accept or record repairs. Open your account : null}\n \n {viewer.manage && (repair.status === \"REPORTED\" || repair.requests.length > 0) ? : null}\n {repair.money ? : null}\n
    \n }\n >\n
    \n \n {repair.item.name} ({repair.item.sku}) },\n ...(repair.asset ? [{ label: \"Asset\", value: {repair.asset.assetTag} }] : []),\n { label: \"Urgency\", value: SEVERITY_LABEL[repair.severity] },\n { label: \"Reported\", value: `${repair.reportedLabel}${repair.reportedBy ? ` by ${repair.reportedBy}` : \"\"}` },\n { label: \"Out of use\", value: repair.held ? `${repair.held.quantity}${repair.held.location ? ` at ${repair.held.location}` : \"\"}` : \"No\" },\n ...(repair.incidentLoanId && viewer.viewAll ? [{ label: \"From a loan\", value: Open the loan }] : []),\n { label: \"Technician\", value: repair.technician?.name ?? \"Not assigned yet\" },\n ]}\n />\n

    {repair.description}

    \n {repair.photos.length ? (\n \n ) : null}\n
    \n {repair.fix.remarks || canWork ? (\n \n {repair.fix.remarks ?

    {repair.fix.remarks}

    : null}\n {repair.fix.partsUsed ?

    Parts: {repair.fix.partsUsed}

    : null}\n {repair.closeNote ?

    Closing note: {repair.closeNote}

    : null}\n {canWork ? : null}\n
    \n ) : null}\n {repair.purchases.length ? (\n \n
      {repair.purchases.map((purchase) =>
    • {purchase.name} · {purchase.status.toLowerCase()}
    • )}
    \n
    \n ) : null}\n \n
    \n \n );\n}\n" }, { "file": "app/s/[spaceSlug]/repairs/new/page.tsx", @@ -185,15 +185,15 @@ }, { "file": "app/s/[spaceSlug]/repairs/page.tsx", - "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport type { Metadata } from \"next\";\nimport Link from \"next/link\";\nimport { RepairStatusChip, SeverityChip, PayoutChip, RequestChip } from \"@/components/repairs/RepairChips\";\nimport { buttonClasses } from \"@/components/ui/button\";\nimport { Callout, EmptyState, PageHeader } from \"@/components/ui/display\";\nimport { ListLayout } from \"@/components/ui/layouts\";\nimport { NavTabs } from \"@/components/ui/tabs\";\nimport { Table, TBody, TD, TH, THead, TR } from \"@/components/ui/table\";\nimport { formatMoney } from \"@/lib/format/money\";\nimport { requireSpace } from \"@/lib/permissions\";\nimport { listRepairs, repairFilter, repairViewer, REPAIR_FILTERS } from \"@/lib/repairs\";\nimport { canReportRepairs } from \"@/lib/roles\";\n\nexport const metadata: Metadata = { title: \"Repairs\" };\n\nexport default async function RepairsPage({ params, searchParams }: PageProps<\"/s/[spaceSlug]/repairs\">) {const { spaceSlug } = await params;\nconst query = await searchParams;\nconst { viewer, filter, rows, base, tabs, canReport, currency, description } = await invokeBackend(\"view\", \"s-spaceSlug-repairs-page:loadRepairsPage\", [{ params, searchParams }]);\n\nreturn (\n \n {viewer.manage ? Technicians : null}\n {viewer.pay ? Payouts : null}\n {canReport ? Report a problem : null}\n
    \n }\n />\n }\n toolbar={}\n >\n {viewer.needsMfa ? Technicians need one before they can accept or record work. Open security settings : null}\n {rows.length === 0 ? (\n {canReport ? \"Report a problem when something is damaged or broken.\" : \"Requests sent to you show up here.\"}\n ) : (\n \n \n \n \n \n \n \n {rows.some((row) => row.money) ? : null}\n \n \n \n \n {rows.map((row) => (\n \n \n \n \n \n {rows.some((entry) => entry.money) ? (\n \n ) : null}\n \n \n ))}\n \n
    RepairItemStatusTechnicianAmountReported
    \n {row.reference}\n \n {row.title}\n {[row.item, row.assetTag].filter(Boolean).join(\" · \")}\n \n \n \n \n {row.myRequest ? : null}\n {viewer.manage && row.status === \"REPORTED\" && row.waitingOn > 0 ? Waiting on {row.waitingOn} {row.waitingOn === 1 ? \"answer\" : \"answers\"} : null}\n \n {row.technician ?? \"—\"}\n {row.money ? (\n \n {row.money.approvedMinor !== null ? formatMoney(row.money.approvedMinor, currency) : row.money.askedMinor !== null ? `${formatMoney(row.money.askedMinor, currency)} asked` : \"—\"}\n \n \n ) : null}\n {row.reportedLabel}
    \n )}\n \n );\n}\n" + "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport type { Metadata } from \"next\";\nimport Link from \"next/link\";\nimport { RepairStatusChip, SeverityChip, PayoutChip, RequestChip } from \"@/components/repairs/RepairChips\";\nimport { buttonClasses } from \"@/components/ui/button\";\nimport { Callout, EmptyState, PageHeader } from \"@/components/ui/display\";\nimport { ListLayout } from \"@/components/ui/layouts\";\nimport { NavTabs } from \"@/components/ui/tabs\";\nimport { Table, TBody, TD, TH, THead, TR } from \"@/components/ui/table\";\nimport { formatMoney } from \"@/lib/format/money\";\nimport { requireSpace } from \"@/lib/permissions\";\nimport { listRepairs, repairFilter, repairViewer, REPAIR_FILTERS } from \"@/lib/repairs\";\nimport { canReportRepairs } from \"@/lib/roles\";\n\nexport const metadata: Metadata = { title: \"Repairs\" };\n\nexport default async function RepairsPage({ params, searchParams }: PageProps<\"/s/[spaceSlug]/repairs\">) {const { spaceSlug } = await params;\nconst query = await searchParams;\nconst { viewer, filter, rows, base, tabs, canReport, currency, description } = await invokeBackend(\"view\", \"s-spaceSlug-repairs-page:loadRepairsPage\", [{ params, searchParams }]);\n\nreturn (\n \n {viewer.manage ? Technicians : null}\n {viewer.pay ? Payouts : null}\n {canReport ? Report a problem : null}\n
    \n }\n />\n }\n toolbar={}\n >\n {viewer.needsMfa ? Technicians need one before they can accept or record work. Open security settings : null}\n {rows.length === 0 ? (\n {canReport ? \"Report a problem when something is damaged or broken.\" : \"Requests sent to you show up here.\"}\n ) : (\n \n \n \n \n \n \n \n {rows.some((row) => row.money) ? : null}\n \n \n \n \n {rows.map((row) => (\n \n \n \n \n \n {rows.some((entry) => entry.money) ? (\n \n ) : null}\n \n \n ))}\n \n
    RepairItemStatusTechnicianAmountReported
    \n {row.reference}\n \n {row.title}\n {[row.item, row.assetTag].filter(Boolean).join(\" · \")}\n \n \n \n \n {row.myRequest ? : null}\n {viewer.manage && row.status === \"REPORTED\" && row.waitingOn > 0 ? Waiting on {row.waitingOn} {row.waitingOn === 1 ? \"answer\" : \"answers\"} : null}\n \n {row.technician ?? \"—\"}\n {row.money ? (\n \n {row.money.approvedMinor !== null ? formatMoney(row.money.approvedMinor, currency) : row.money.askedMinor !== null ? `${formatMoney(row.money.askedMinor, currency)} asked` : \"—\"}\n \n \n ) : null}\n {row.reportedLabel}
    \n )}\n \n );\n}\n" }, { "file": "app/s/[spaceSlug]/repairs/payouts/page.tsx", - "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport type { Metadata } from \"next\";\nimport Link from \"next/link\";\nimport { EmptyState, PageHeader, StatTile } from \"@/components/ui/display\";\nimport { ListLayout } from \"@/components/ui/layouts\";\nimport { Table, TBody, TD, TH, THead, TR } from \"@/components/ui/table\";\nimport { repairRef } from \"@/lib/catalogue/references\";\nimport { prisma } from \"@/lib/db\";\nimport { PAYOUT_METHOD_LABEL } from \"@/lib/finance/labels\";\nimport { formatDate } from \"@/lib/format/datetime\";\nimport { formatMoney } from \"@/lib/format/money\";\nimport { requireSpace } from \"@/lib/permissions\";\n\nexport const metadata: Metadata = { title: \"Repair payouts\" };\n\n/** Every repair payout (append-only), newest first. Inventory managers only. */\nexport default async function RepairPayoutsPage({ params }: PageProps<\"/s/[spaceSlug]/repairs/payouts\">) {const { spaceSlug } = await params;\nconst { space, payouts, unpaid, total, currency, slug } = await invokeBackend(\"view\", \"s-spaceSlug-repairs-payouts-page:loadRepairPayoutsPage\", [{ params, }]);\n\nreturn (\n }>\n
    \n \n 0 ? \"attention\" : \"default\"} />\n
    \n {payouts.length === 0 ? Approve a fixed repair's amount, then record the payout on the repair. : (\n \n \n \n \n \n {payouts.map((payout) => (\n \n \n \n \n \n \n \n ))}\n \n
    PaidRepairTechnicianMethodAmount
    {formatDate(payout.paidAt, space.timezone)}{repairRef(payout.issue.number)}{payout.issue.title}{payout.technician.name}{PAYOUT_METHOD_LABEL[payout.method]}{payout.reference ? {payout.reference} : null}{formatMoney(payout.amountMinor, currency)}
    \n )}\n
    \n );\n}\n" + "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport type { Metadata } from \"next\";\nimport Link from \"next/link\";\nimport { EmptyState, PageHeader, StatTile } from \"@/components/ui/display\";\nimport { ListLayout } from \"@/components/ui/layouts\";\nimport { Table, TBody, TD, TH, THead, TR } from \"@/components/ui/table\";\nimport { repairRef } from \"@/lib/catalogue/references\";\nimport { prisma } from \"@/lib/db\";\nimport { PAYOUT_METHOD_LABEL } from \"@/lib/finance/labels\";\nimport { formatDate } from \"@/lib/format/datetime\";\nimport { formatMoney } from \"@/lib/format/money\";\nimport { requireSpace } from \"@/lib/permissions\";\n\nexport const metadata: Metadata = { title: \"Repair payouts\" };\n\n/** Every repair payout (append-only), newest first. Inventory managers only. */\nexport default async function RepairPayoutsPage({ params }: PageProps<\"/s/[spaceSlug]/repairs/payouts\">) {const { spaceSlug } = await params;\nconst { space, payouts, unpaid, total, currency, slug } = await invokeBackend(\"view\", \"s-spaceSlug-repairs-payouts-page:loadRepairPayoutsPage\", [{ params, }]);\n\nreturn (\n }>\n
    \n \n 0 ? \"attention\" : \"default\"} />\n
    \n {payouts.length === 0 ? Approve a fixed repair's amount, then record the payout on the repair. : (\n \n \n \n \n \n {payouts.map((payout) => (\n \n \n \n \n \n \n \n ))}\n \n
    PaidRepairTechnicianMethodAmount
    {formatDate(payout.paidAt, space.timezone)}{repairRef(payout.issue.number)}{payout.issue.title}{payout.technician.name}{PAYOUT_METHOD_LABEL[payout.method]}{payout.reference ? {payout.reference} : null}{formatMoney(payout.amountMinor, currency)}
    \n )}\n
    \n );\n}\n" }, { "file": "app/s/[spaceSlug]/repairs/technicians/page.tsx", - "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport type { Metadata } from \"next\";\nimport { MessagingCard } from \"@/components/repairs/MessagingCard\";\nimport { TechnicianDialog } from \"@/components/repairs/TechnicianDialog\";\nimport { TechniciansTable } from \"@/components/repairs/TechniciansTable\";\nimport { PageHeader, SectionHeading } from \"@/components/ui/display\";\nimport { ListLayout } from \"@/components/ui/layouts\";\nimport { prisma } from \"@/lib/db\";\nimport { requireSpace } from \"@/lib/permissions\";\nimport { chatSettingsView } from \"@/lib/repairs\";\n\nexport const metadata: Metadata = { title: \"Technicians\" };\n\nexport default async function TechniciansPage({ params }: PageProps<\"/s/[spaceSlug]/repairs/technicians\">) {const { spaceSlug } = await params;\nconst { space, members, messaging, rows, candidates } = await invokeBackend(\"view\", \"s-spaceSlug-repairs-technicians-page:loadTechniciansPage\", [{ params, }]);\n\nreturn (\n } />}\n >\n \n
    \n \n \n
    \n \n );\n}\n" + "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport type { Metadata } from \"next\";\nimport { MessagingCard } from \"@/components/repairs/MessagingCard\";\nimport { TechnicianDialog } from \"@/components/repairs/TechnicianDialog\";\nimport { TechniciansTable } from \"@/components/repairs/TechniciansTable\";\nimport { CollapsibleSection } from \"@/components/ui/collapsible\";\nimport { PageHeader } from \"@/components/ui/display\";\nimport { ListLayout } from \"@/components/ui/layouts\";\nimport { prisma } from \"@/lib/db\";\nimport { requireSpace } from \"@/lib/permissions\";\nimport { chatSettingsView } from \"@/lib/repairs\";\n\nexport const metadata: Metadata = { title: \"Technicians\" };\n\nexport default async function TechniciansPage({ params }: PageProps<\"/s/[spaceSlug]/repairs/technicians\">) {const { spaceSlug } = await params;\nconst { space, members, messaging, rows, candidates } = await invokeBackend(\"view\", \"s-spaceSlug-repairs-technicians-page:loadTechniciansPage\", [{ params, }]);\n\nreturn (\n } />}\n >\n \n \n \n \n \n );\n}\n" }, { "file": "app/s/[spaceSlug]/reports/page.tsx", @@ -221,7 +221,7 @@ }, { "file": "app/s/[spaceSlug]/settings/roles/page.tsx", - "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport Link from \"next/link\";\nimport { SettingsTabs } from \"@/components/settings/SettingsTabs\";\nimport { Card, CardHeader, Chip, PageHeader, SectionHeading } from \"@/components/ui/display\";\nimport { SettingsLayout } from \"@/components/ui/layouts\";\nimport { Table, TBody, TD, TH, THead, TR } from \"@/components/ui/table\";\nimport { requireSpace } from \"@/lib/permissions\";\nimport { ROLE_MATRIX, roleDescription, roleLabel, SPACE_ROLES } from \"@/lib/roles\";\n\nexport const metadata = { title: \"Roles\" };\n\nexport default async function RolesPage({ params }: PageProps<\"/s/[spaceSlug]/settings/roles\">) {const { spaceSlug } = await params;\nconst { roles, matrix, roleCards, slug } = await invokeBackend(\"view\", \"s-spaceSlug-settings-roles-page:loadRolesPage\", [{ params, }]);\n\nreturn (\n }\n tabs={}\n >\n
    \n \n \n Change someone's role on the members page.} />\n
    \n {roleCards.map((card) => (\n
    \n
    {card.label}
    \n
    {card.description}
    \n
    \n ))}\n
    \n
    \n \n \n \n \n {roles.map((role) => )}\n \n \n \n {matrix.map((row) => (\n \n \n {row.allowed.map((allowed, index) => (\n \n ))}\n \n ))}\n \n
    Capability{roleLabel(role)}
    {row.capability}\n {`${roleLabel(roles[index]!)}: `}\n {allowed ? Yes : No}\n
    \n
    \n \n );\n}\n" + "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport Link from \"next/link\";\nimport { SettingsTabs } from \"@/components/settings/SettingsTabs\";\nimport { CollapsibleCard } from \"@/components/ui/collapsible\";\nimport { Chip, PageHeader, SectionHeading } from \"@/components/ui/display\";\nimport { SettingsLayout } from \"@/components/ui/layouts\";\nimport { Table, TBody, TD, TH, THead, TR } from \"@/components/ui/table\";\nimport { requireSpace } from \"@/lib/permissions\";\nimport { ROLE_MATRIX, roleDescription, roleLabel, SPACE_ROLES } from \"@/lib/roles\";\n\nexport const metadata = { title: \"Roles\" };\n\nexport default async function RolesPage({ params }: PageProps<\"/s/[spaceSlug]/settings/roles\">) {const { spaceSlug } = await params;\nconst { roles, matrix, roleCards, slug } = await invokeBackend(\"view\", \"s-spaceSlug-settings-roles-page:loadRolesPage\", [{ params, }]);\n\nreturn (\n }\n tabs={}\n >\n
    \n \n Change someone's role on the members page.}>\n
    \n {roleCards.map((card) => (\n
    \n
    {card.label}
    \n
    {card.description}
    \n
    \n ))}\n
    \n
    \n \n \n \n \n {roles.map((role) => )}\n \n \n \n {matrix.map((row) => (\n \n \n {row.allowed.map((allowed, index) => (\n \n ))}\n \n ))}\n \n
    Capability{roleLabel(role)}
    {row.capability}\n {`${roleLabel(roles[index]!)}: `}\n {allowed ? Yes : No}\n
    \n
    \n \n );\n}\n" }, { "file": "app/s/[spaceSlug]/setup/page.tsx", @@ -237,7 +237,7 @@ }, { "file": "app/s/[spaceSlug]/transfers/[transferId]/page.tsx", - "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport Link from \"next/link\";\nimport { notFound } from \"next/navigation\";\nimport { EvidenceGallery } from \"@/components/desk/EvidenceGallery\";\nimport { Timeline } from \"@/components/desk/Timeline\";\nimport { TransferStatusChip } from \"@/components/inventory/StatusChips\";\nimport { TransferLinesTable } from \"@/components/transfers/TransferLinesTable\";\nimport { TransferSteps } from \"@/components/transfers/TransferSteps\";\nimport { Callout, Card, CardHeader, DetailList, PageHeader } from \"@/components/ui/display\";\nimport { DetailLayout } from \"@/components/ui/layouts\";\nimport { transferRef } from \"@/lib/catalogue/references\";\nimport { locationOptions } from \"@/lib/desk/locations\";\nimport { allowedTransferActions } from \"@/lib/desk/transferLogic\";\nimport { pickableAssets, transferDetail } from \"@/lib/desk/transfers\";\nimport { formatDayShort, formatDayTime, formatRelativeTime } from \"@/lib/format/datetime\";\nimport { findSpaceForUser, requireSpace } from \"@/lib/permissions\";\nimport { HandoverDone } from \"@/components/desk/HandoverDone\";\nimport { doneFor } from \"@/lib/desk/stepper\";\n\nconst SIDE_WORD = { source: \"Sending\", destination: \"Receiving\", both: \"Sending and receiving\" } as const;\n\nexport default async function TransferPage({ params, searchParams }: PageProps<\"/s/[spaceSlug]/transfers/[transferId]\">) {const { spaceSlug, transferId } = await params;\nconst query = await searchParams;\nconst { transfer, slug, allowed, lines, locations, pickable, minPhotos, otherHref, otherName, reference, facts, notice, timeline, evidence, status, done, transferIdValue, destLocationId, sourceLocationId } = await invokeBackend(\"view\", \"s-spaceSlug-transfers-transferId-page:loadTransferPage\", [{ params, searchParams }]);\n\nreturn (\n Transfer } title={reference} description={transfer.betweenSpaces ? `${transfer.source.name} to ${transfer.dest.name}` : \"Between shelves of this space\"} />}\n aside={\n
    \n \n \n \n \n {otherHref ? Open as {otherName} : null}\n
    \n }\n >\n
    \n {done ? : null}\n {notice ? {notice} : null}\n \n \n \n \n
    \n
    \n );\n}\n" + "source": "import type { Views } from \"@/contracts\";\nimport { invokeBackend } from \"@/lib/frontend/client\";\nimport Link from \"next/link\";\nimport { notFound } from \"next/navigation\";\nimport { EvidenceGallery } from \"@/components/desk/EvidenceGallery\";\nimport { Timeline } from \"@/components/desk/Timeline\";\nimport { TransferStatusChip } from \"@/components/inventory/StatusChips\";\nimport { TransferLinesTable } from \"@/components/transfers/TransferLinesTable\";\nimport { TransferSteps } from \"@/components/transfers/TransferSteps\";\nimport { CollapsibleCard } from \"@/components/ui/collapsible\";\nimport { Callout, DetailList, PageHeader } from \"@/components/ui/display\";\nimport { DetailLayout } from \"@/components/ui/layouts\";\nimport { transferRef } from \"@/lib/catalogue/references\";\nimport { locationOptions } from \"@/lib/desk/locations\";\nimport { allowedTransferActions } from \"@/lib/desk/transferLogic\";\nimport { pickableAssets, transferDetail } from \"@/lib/desk/transfers\";\nimport { formatDayShort, formatDayTime, formatRelativeTime } from \"@/lib/format/datetime\";\nimport { findSpaceForUser, requireSpace } from \"@/lib/permissions\";\nimport { HandoverDone } from \"@/components/desk/HandoverDone\";\nimport { doneFor } from \"@/lib/desk/stepper\";\n\nconst SIDE_WORD = { source: \"Sending\", destination: \"Receiving\", both: \"Sending and receiving\" } as const;\n\nexport default async function TransferPage({ params, searchParams }: PageProps<\"/s/[spaceSlug]/transfers/[transferId]\">) {const { spaceSlug, transferId } = await params;\nconst query = await searchParams;\nconst { transfer, slug, allowed, lines, locations, pickable, minPhotos, otherHref, otherName, reference, facts, notice, timeline, evidence, status, done, transferIdValue, destLocationId, sourceLocationId } = await invokeBackend(\"view\", \"s-spaceSlug-transfers-transferId-page:loadTransferPage\", [{ params, searchParams }]);\n\nreturn (\n Transfer } title={reference} description={transfer.betweenSpaces ? `${transfer.source.name} to ${transfer.dest.name}` : \"Between shelves of this space\"} />}\n aside={\n
    \n \n \n \n {otherHref ? Open as {otherName} : null}\n
    \n }\n >\n
    \n {done ? : null}\n {notice ? {notice} : null}\n \n \n \n \n
    \n \n );\n}\n" }, { "file": "app/s/[spaceSlug]/transfers/new/page.tsx", diff --git a/tests/e2e/member-loans.spec.ts b/tests/e2e/member-loans.spec.ts index c911313..237b844 100644 --- a/tests/e2e/member-loans.spec.ts +++ b/tests/e2e/member-loans.spec.ts @@ -21,7 +21,7 @@ test("a member sends a catalogue request and finds it under my loans", async ({ await expect(page.getByText("Ready to collect. Come to the desk; staff scan and photograph the items as they hand them over.", { exact: true })).toBeVisible(); await expect(page.getByRole("region", { name: "Loan items", exact: true })).toContainText("Safety goggles"); await expect(page.getByRole("region", { name: "Details", exact: true }).getByText(purpose, { exact: true })).toBeVisible(); - await page.getByRole("link", { name: /Back to my loans$/ }).click(); + await page.getByRole("main").getByRole("link", { name: "My loans", exact: true }).click(); await expect(page.getByRole("heading", { name: "My loans", level: 1 })).toBeVisible(); const row = page.getByRole("row").filter({ has: page.getByRole("link", { name: reference, exact: true }) }); await expect(row).toContainText("Safety goggles"); diff --git a/tests/unit/design-fidelity.test.ts b/tests/unit/design-fidelity.test.ts index fbc72ae..2178835 100644 --- a/tests/unit/design-fidelity.test.ts +++ b/tests/unit/design-fidelity.test.ts @@ -180,7 +180,7 @@ describe("design source guards across every area", () => { for (const file of files.filter(f => f.endsWith(".tsx"))) { const source = ts.createSourceFile(file, read(file), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); const icons = new Set(); - const approved = new Set(["Archive", "Cpu", "MapPin", "Search", "Plus", "ArrowLeft", "ArrowUpRight", "ChevronsUpDown", "Info", "TriangleAlert", "CircleCheck", "Ellipsis", "Sun", "Moon", "LogOut", "Package", "Boxes", "QrCode", "ScanLine", "Camera", "Warehouse", "ArrowLeftRight", "Truck", "Wrench", "ClipboardCheck", "Tag", "Printer", "Upload", "Download", "CreditCard", "Undo2", "Bell", "ChevronDown", "Menu", "X"]); + const approved = new Set(["Archive", "Cpu", "MapPin", "Search", "Plus", "ArrowLeft", "ArrowUpRight", "ChevronsUpDown", "Info", "TriangleAlert", "CircleCheck", "Ellipsis", "Sun", "Moon", "LogOut", "Package", "Boxes", "QrCode", "ScanLine", "Camera", "Warehouse", "ArrowLeftRight", "Truck", "Wrench", "ClipboardCheck", "Tag", "Printer", "Upload", "Download", "CreditCard", "Undo2", "Bell", "ChevronDown", "Menu", "X", "CornerDownRight"]); for (const statement of source.statements) if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "lucide-react") { const bindings = statement.importClause?.namedBindings; if (bindings && ts.isNamedImports(bindings)) for (const icon of bindings.elements) { From d36d19382dc20f9cd55b33c5af4635e5d7cb2dfb Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Sun, 25 Oct 2026 18:29:18 +0530 Subject: [PATCH 06/11] fix(shell): the colour-mode circle grows cleanly from the account menu Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ app/styles/global-elements.css | 9 +++++++++ components/chrome/AppShell.tsx | 17 +++++++++++++++-- components/providers.tsx | 7 +++++-- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8541e87..bde287f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,10 @@ The first public release: everything since the split from SpaceWorks ### Fixed +- The light/dark switch now grows one clean feathered circle from the + account menu: controls no longer run their own colour transitions inside + the circle (which looked half-switched), the menu closes before the switch + starts, and a pointer choice no longer lights a focus ring on the trigger. - Signing in with an authenticator app never asked for the code (the form sent a missing code as the text "undefined"), so accounts with two-step verification could not sign in with a password or an email link. diff --git a/app/styles/global-elements.css b/app/styles/global-elements.css index a33dca6..0a5f699 100644 --- a/app/styles/global-elements.css +++ b/app/styles/global-elements.css @@ -218,6 +218,15 @@ mask-image: radial-gradient(circle at var(--sw-reveal-x) var(--sw-reveal-y), var(--sw-ink) calc(var(--sw-reveal) - 160px), transparent var(--sw-reveal)); } +/* While the circle grows, the page under it doesn't run its own colour + transitions (the new mode must arrive in one step inside the circle). The + toggle icon keeps its cross-rotation. */ +.sw-theme-switching *:not(.sw-mode-icon), +.sw-theme-switching *::before, +.sw-theme-switching *::after { + transition: none !important; +} + /* Fallback when View Transitions aren't available: a gentle colour cross-fade. */ .sw-theme-fade, .sw-theme-fade *, diff --git a/components/chrome/AppShell.tsx b/components/chrome/AppShell.tsx index b927502..49286eb 100644 --- a/components/chrome/AppShell.tsx +++ b/components/chrome/AppShell.tsx @@ -68,6 +68,7 @@ function SpaceSwitcher({ data, onNavigate }: { data: ShellData; onNavigate?: () function AccountMenu({ data, onNavigate }: { data: ShellData; onNavigate?: () => void }) { const { mode, toggleColorMode } = useAppColorMode(); const triggerRef = useRef(null); + const byPointer = useRef(false); const name = data.user.name ?? data.user.email; return ( @@ -82,7 +83,16 @@ function AccountMenu({ data, onNavigate }: { data: ShellData; onNavigate?: () => - + { byPointer.current = true; }} + onKeyDown={() => { byPointer.current = false; }} + // Keyboard users get focus back on the trigger; a pointer choice doesn't + // light up a focus ring in the middle of the colour switch. + onCloseAutoFocus={(event) => { if (byPointer.current) event.preventDefault(); byPointer.current = false; }} + > {name} Account settings @@ -95,8 +105,11 @@ function AccountMenu({ data, onNavigate }: { data: ShellData; onNavigate?: () => : } onSelect={() => { + // Let the menu close (and paint) first, so the snapshot the circle + // grows over has no open menu in it; then grow from the trigger. const rect = triggerRef.current?.getBoundingClientRect(); - toggleColorMode(rect ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : undefined); + const origin = rect ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : undefined; + requestAnimationFrame(() => requestAnimationFrame(() => toggleColorMode(origin))); }} > {mode === "dark" ? "Light mode" : "Dark mode"} diff --git a/components/providers.tsx b/components/providers.tsx index 24b1037..0d28600 100644 --- a/components/providers.tsx +++ b/components/providers.tsx @@ -92,9 +92,12 @@ function transitionColorMode(commit: () => void, origin?: ColorModeOrigin) { const radius = revealRadius({ x, y }, viewport); root.style.setProperty("--sw-reveal-x", `${x}px`); root.style.setProperty("--sw-reveal-y", `${y}px`); - root.classList.add(plan === "reveal-soft" ? "sw-theme-reveal-soft" : "sw-theme-reveal"); + // The new mode is a live image: without this, every control with a colour + // transition fades from light to dark inside the circle, so it reads as a + // muddy flicker instead of one clean edge (found with the reveal probe). + root.classList.add(plan === "reveal-soft" ? "sw-theme-reveal-soft" : "sw-theme-reveal", "sw-theme-switching"); const cleanUp = () => { - root.classList.remove("sw-theme-reveal", "sw-theme-reveal-soft"); + root.classList.remove("sw-theme-reveal", "sw-theme-reveal-soft", "sw-theme-switching"); root.style.removeProperty("--sw-reveal-x"); root.style.removeProperty("--sw-reveal-y"); }; From 13a5852e9412dc79da39089bb29901decc3d01a6 Mon Sep 17 00:00:00 2001 From: Shaan Shoukath Date: Sun, 25 Oct 2026 18:50:42 +0530 Subject: [PATCH 07/11] fix(ui): table cells and stat values no longer split words letter by letter; field labels stay wired Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 --- app/s/[spaceSlug]/items/[itemId]/page.tsx | 4 ++-- components/ui/display.tsx | 2 +- components/ui/field.tsx | 14 +++++++++++++- components/ui/table.tsx | 3 ++- frontend/contracts.d.ts | 2 +- frontend/pages.json | 2 +- 6 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/s/[spaceSlug]/items/[itemId]/page.tsx b/app/s/[spaceSlug]/items/[itemId]/page.tsx index 4619919..481ab33 100644 --- a/app/s/[spaceSlug]/items/[itemId]/page.tsx +++ b/app/s/[spaceSlug]/items/[itemId]/page.tsx @@ -124,9 +124,9 @@ export default async function ItemPage({ params }: PageProps<"/s/[spaceSlug]/ite {low ? Low stock : null} {archived ? Archived : null}
    -
    +
    {stats.map((stat) => )} - +
    diff --git a/components/ui/display.tsx b/components/ui/display.tsx index f73ef38..5cd4589 100644 --- a/components/ui/display.tsx +++ b/components/ui/display.tsx @@ -192,7 +192,7 @@ export function StatTile({ label, value, hint, progress, tone = "default", class const body = ( <> {label} - {value} + {value} {hint ? {hint} : null} {progress ? : null} diff --git a/components/ui/field.tsx b/components/ui/field.tsx index dbfd6e7..5d57de9 100644 --- a/components/ui/field.tsx +++ b/components/ui/field.tsx @@ -4,7 +4,9 @@ import { cloneElement, forwardRef, isValidElement, + useEffect, useId, + useRef, useState, type InputHTMLAttributes, type ReactElement, @@ -46,8 +48,18 @@ export function Field({ label, optional, helperText, error, hideLabel, className required: required ?? (childProps.required as boolean | undefined), }) : children; + // A control rendered by a Server Component reaches the server render without + // the cloned id (it is only wired on the client), and React does not patch + // attributes after hydration, so the label would point nowhere. Re-wire it. + const box = useRef(null); + useEffect(() => { + const control = box.current?.querySelector("input, select, textarea, [role=combobox]"); + if (!control || control.id) return; + control.id = controlId; + if (describedBy && !control.getAttribute("aria-describedby")) control.setAttribute("aria-describedby", describedBy); + }, [controlId, describedBy]); return ( -
    +