diff --git a/.do/app.yaml b/.do/app.yaml index 6f41597..f43aeb5 100644 --- a/.do/app.yaml +++ b/.do/app.yaml @@ -19,7 +19,7 @@ services: - name: web image: registry_type: GHCR - registry: spaceworks-hq + registry: spaceworks-labs repository: spaceworks-inventory-backend tag: REPLACE_WITH_IMMUTABLE_RELEASE http_port: 3000 @@ -40,7 +40,7 @@ jobs: kind: PRE_DEPLOY image: registry_type: GHCR - registry: spaceworks-hq + registry: spaceworks-labs repository: spaceworks-inventory-backend tag: REPLACE_WITH_IMMUTABLE_RELEASE instance_size_slug: apps-s-1vcpu-1gb @@ -50,7 +50,7 @@ workers: - name: worker image: registry_type: GHCR - registry: spaceworks-hq + registry: spaceworks-labs repository: spaceworks-inventory-backend tag: REPLACE_WITH_IMMUTABLE_RELEASE instance_size_slug: apps-s-1vcpu-1gb @@ -59,7 +59,7 @@ workers: - name: beat image: registry_type: GHCR - registry: spaceworks-hq + registry: spaceworks-labs repository: spaceworks-inventory-backend tag: REPLACE_WITH_IMMUTABLE_RELEASE instance_size_slug: apps-s-1vcpu-1gb diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e57090f..8325cc3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -113,8 +113,12 @@ jobs: } >> "$GITHUB_OUTPUT" echo "Version $version (GitHub Release: $release, pre-release: $prerelease)" + # The quality job is skipped on main (the workflow_run gate already passed), and + # a skipped job skips everything downstream by default, so each later job says + # explicitly that it follows a successful previous step. build: needs: prepare + if: ${{ !cancelled() && needs.prepare.result == 'success' }} strategy: fail-fast: true matrix: @@ -211,6 +215,7 @@ jobs: publish: needs: [prepare, build] + if: ${{ !cancelled() && needs.build.result == 'success' }} runs-on: ubuntu-latest permissions: contents: read @@ -259,7 +264,7 @@ jobs: github-release: needs: [prepare, publish] - if: needs.prepare.outputs.release == 'true' + if: ${{ !cancelled() && needs.publish.result == 'success' && needs.prepare.outputs.release == 'true' }} runs-on: ubuntu-latest permissions: contents: write @@ -318,6 +323,7 @@ jobs: deploy: needs: [prepare, github-release] if: >- + !cancelled() && needs.github-release.result == 'success' && needs.github-release.outputs.created == 'true' && needs.prepare.outputs.prerelease == 'false' && vars.SWI_DEPLOY == 'true' @@ -337,6 +343,7 @@ jobs: retention: needs: [prepare, publish] + if: ${{ !cancelled() && needs.publish.result == 'success' }} runs-on: ubuntu-latest permissions: contents: read diff --git a/AGENTS.md b/AGENTS.md index 0cfca46..bcd637c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex # Space Works Inventory agent guide -> **Start here:** `docs/DECISIONS.md` explains what this repo is (a standalone app split out of SpaceWorks, part of the SpaceWorks-HQ family), every decision already made and the checks left for the owner. +> **Start here:** `docs/DECISIONS.md` explains what this repo is (a standalone app split out of SpaceWorks, part of the SpaceWorks-Labs family), every decision already made and the checks left for the owner. `CLAUDE.md` is a byte-identical copy of this file so every coding agent reads the same rules. Edit `AGENTS.md`, then copy it over `CLAUDE.md` @@ -147,7 +147,7 @@ the role and checked by `lib/lending/access.ts`. ## Design system Follow [docs/DESIGN.md](docs/DESIGN.md) (source of truth: -[SpaceWorks-Design](https://spaceworks-hq.github.io/SpaceWorks-Design/)). +[SpaceWorks-Design](https://spaceworks-labs.github.io/SpaceWorks-Design/)). Colours only from tokens (`app/styles/tokens.css` via the utilities in `app/globals.css`: `bg-canvas`, `bg-panel`, `bg-primary`, `bg-sidebar`, `sw-*`); never hard-coded hex. Gilroy everywhere, Unbounded (`font-accent`) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3cd3e4..d98a274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,17 @@ 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". +- 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 @@ -79,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. @@ -150,5 +165,5 @@ lending desk for makerspaces, with several makerspaces on one install. DigitalOcean App Platform spec, a Cloudflare cron Worker, and multi-arch backend and frontend images on GitHub Packages. -[Unreleased]: https://github.com/SpaceWorks-HQ/SpaceWorks-Inventory/commits/dev -[0.1.0]: https://github.com/SpaceWorks-HQ/SpaceWorks-Inventory/releases +[Unreleased]: https://github.com/SpaceWorks-Labs/SpaceWorks-Inventory/commits/dev +[0.1.0]: https://github.com/SpaceWorks-Labs/SpaceWorks-Inventory/releases diff --git a/CLAUDE.md b/CLAUDE.md index 0cfca46..bcd637c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex # Space Works Inventory agent guide -> **Start here:** `docs/DECISIONS.md` explains what this repo is (a standalone app split out of SpaceWorks, part of the SpaceWorks-HQ family), every decision already made and the checks left for the owner. +> **Start here:** `docs/DECISIONS.md` explains what this repo is (a standalone app split out of SpaceWorks, part of the SpaceWorks-Labs family), every decision already made and the checks left for the owner. `CLAUDE.md` is a byte-identical copy of this file so every coding agent reads the same rules. Edit `AGENTS.md`, then copy it over `CLAUDE.md` @@ -147,7 +147,7 @@ the role and checked by `lib/lending/access.ts`. ## Design system Follow [docs/DESIGN.md](docs/DESIGN.md) (source of truth: -[SpaceWorks-Design](https://spaceworks-hq.github.io/SpaceWorks-Design/)). +[SpaceWorks-Design](https://spaceworks-labs.github.io/SpaceWorks-Design/)). Colours only from tokens (`app/styles/tokens.css` via the utilities in `app/globals.css`: `bg-canvas`, `bg-panel`, `bg-primary`, `bg-sidebar`, `sw-*`); never hard-coded hex. Gilroy everywhere, Unbounded (`font-accent`) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e95047..a578353 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,7 +65,7 @@ and commit the regenerated files. at or under 300 lines; ESLint's `max-lines` enforces it. Split by responsibility behind an `index.ts`. Generated files are exempt. - **Design system.** Follow [docs/DESIGN.md](docs/DESIGN.md) (source of truth: - [SpaceWorks-Design](https://spaceworks-hq.github.io/SpaceWorks-Design/)). + [SpaceWorks-Design](https://spaceworks-labs.github.io/SpaceWorks-Design/)). Colours come only from tokens, never hard-coded hex; Gilroy everywhere, with Unbounded for one accent phrase on auth and empty-state heroes; light and dark mode for everything; status pills always carry a word. diff --git a/README.md b/README.md index 6924008..48f1975 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,10 @@ to members with a QR scan and a photo at every handover, count stock, and move i between makerspaces, all on infrastructure you control. One installation runs several makerspaces, each with its own members, roles, settings and audit trail. It is fully standalone; its REST API and signed webhooks connect it to the other Space Works apps -([Events](https://github.com/SpaceWorks-HQ/SpaceWorks-Events), -[Workspace](https://github.com/SpaceWorks-HQ/SpaceWorks-Workspace), -[Bookings](https://github.com/SpaceWorks-HQ/SpaceWorks-Bookings), -[Machines](https://github.com/SpaceWorks-HQ/SpaceWorks-Machines)) or anything else, whenever you want. +([Events](https://github.com/SpaceWorks-Labs/SpaceWorks-Events), +[Workspace](https://github.com/SpaceWorks-Labs/SpaceWorks-Workspace), +[Bookings](https://github.com/SpaceWorks-Labs/SpaceWorks-Bookings), +[Machines](https://github.com/SpaceWorks-Labs/SpaceWorks-Machines)) or anything else, whenever you want. ## What you can do @@ -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 @@ -106,12 +114,12 @@ shows light mode; every screen is also in dark mode as `-dark.webp` in | | | | --- | --- | -| Space home with the desk queue, transfers and recent stock movements
**Space home**: the desk queue, transfers in and out, low stock | Handing over a loan: a step-by-step flow that starts with scanning each label
**Hand over a loan**: scan, check the lines, photos and remarks | +| Space home with the desk queue, transfers and recent stock movements
**Space home**: desk queues that fold away, transfers, low stock and the bell for notifications | Handing over a loan: a step-by-step flow that starts with scanning each label
**Hand over a loan**: scan, check the lines, photos and remarks | | Member catalogue with availability and a request tray
**Borrow**: members browse and send a request | Loans queue for the lending desk
**Loans**: requests, ready to collect, on loan and overdue | | Stock by location with on hand, held and on loan
**Stock**: on hand, held and on loan per shelf | A transfer between spaces in transit with its timeline
**Transfers** between shelves and between spaces | | Cycle counts list
**Cycle counts** with blind counting and variances | Reports with valuation, lending and exports
**Reports** with CSV and Excel exports | | Repairs list with status, technician and payout
**Repairs**: report, send to technicians, verify and pay | Finance page with money in, out and net by month and category
**Finance**: every payment, refund, payout and purchase in one ledger | -| Item page with stock per location and QR code
**Item**: stock per shelf, assets and its QR label | Scanning a label on a phone
**Scan** any label from a phone | +| Item page with stock per location and QR code
**Item**: stock per shelf, assets and its QR label, each section folds | Scanning a label on a phone
**Scan** any label from a phone | More: [items](docs/screenshots/items.webp), [locations](docs/screenshots/locations.webp), [my loans](docs/screenshots/my-loans.webp), [members](docs/screenshots/members.webp), @@ -132,7 +140,7 @@ On a 64-bit Linux server (Ubuntu/Debian, Fedora/RHEL/Alma/Rocky, Arch or openSUS with `sudo`: ```bash -curl -fsSL https://raw.githubusercontent.com/SpaceWorks-HQ/SpaceWorks-Inventory/main/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/SpaceWorks-Labs/SpaceWorks-Inventory/main/install.sh | bash ``` While the repository is private, give the installer a GitHub token that can read the repository and @@ -140,7 +148,7 @@ its packages (it downloads the release and logs in to GitHub Packages with it): ```bash export SWI_GITHUB_TOKEN= SWI_GITHUB_USER= -curl -fsSL -H "Authorization: Bearer $SWI_GITHUB_TOKEN" https://raw.githubusercontent.com/SpaceWorks-HQ/SpaceWorks-Inventory/main/install.sh | bash +curl -fsSL -H "Authorization: Bearer $SWI_GITHUB_TOKEN" https://raw.githubusercontent.com/SpaceWorks-Labs/SpaceWorks-Inventory/main/install.sh | bash ``` It checks the server and asks every question first: the layout (website and API containers on two @@ -210,7 +218,7 @@ flowchart LR ```

- + Deploy to Render

@@ -248,7 +256,7 @@ development emails (magic links, invitations) are written to `.data/mail/`. ## Design system -Space Works Inventory follows the [Space Works design system](https://spaceworks-hq.github.io/SpaceWorks-Design/): +Space Works Inventory follows the [Space Works design system](https://spaceworks-labs.github.io/SpaceWorks-Design/): neutral first, accent second, and one bold moment per layout. | | Colour | Hex | Used for | @@ -309,7 +317,7 @@ npm run readme:art # README banner and colour swatches Bug reports, ideas and pull requests are all welcome. -- [Report a bug](https://github.com/SpaceWorks-HQ/SpaceWorks-Inventory/issues/new) or open a pull request. +- [Report a bug](https://github.com/SpaceWorks-Labs/SpaceWorks-Inventory/issues/new) or open a pull request. - Branch from `dev` and open your pull request against `dev`, the integration branch. `main` is production; only [@Shaan-Shoukath](https://github.com/Shaan-Shoukath) merges to it. - Found a security issue? Follow [SECURITY.md](SECURITY.md) and report it privately. @@ -321,14 +329,14 @@ availability module, state changes in the workflow modules, and tenant and role Made by **Space Works**, a maker community that started inside TinkerSpace Kochi. -- **Domain.** Ported (not copied) from the original [SpaceWorks](https://github.com/SpaceWorks-HQ/SpaceWorks) +- **Domain.** Ported (not copied) from the original [SpaceWorks](https://github.com/SpaceWorks-Labs/SpaceWorks) makerspace manager's inventory, lending and transfer apps. - **Platform.** The deployment and infrastructure code, background jobs and split-frontend gateway are - adapted from [Space Works Workspace](https://github.com/SpaceWorks-HQ/SpaceWorks-Workspace) (see + adapted from [Space Works Workspace](https://github.com/SpaceWorks-Labs/SpaceWorks-Workspace) (see [NOTICE](NOTICE)); the colour-mode reveal and Patch's walk on the 404 page come from - [Space Works Events](https://github.com/SpaceWorks-HQ/SpaceWorks-Events). + [Space Works Events](https://github.com/SpaceWorks-Labs/SpaceWorks-Events). - **Brand.** The Space Works identity, Patch and the README art come from the - [Space Works design system](https://github.com/SpaceWorks-HQ/SpaceWorks-Design). + [Space Works design system](https://github.com/SpaceWorks-Labs/SpaceWorks-Design). - **Fonts.** Gilroy by Radomir Tinkov, and [Unbounded](https://github.com/googlefonts/unbounded) by the Unbounded Project Authors under the SIL Open Font License 1.1. diff --git a/SECURITY.md b/SECURITY.md index b8a2470..12b9535 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,7 +7,7 @@ discussion or pull request. Use GitHub's private vulnerability reporting: open the repository's **Security** tab and choose **Report a vulnerability** -(). +(). Only the maintainers can see the report. Include: - what an attacker can do, and which role or access they need; 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..481ab33 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}
} @@ -126,18 +124,16 @@ export default async function ItemPage({ params }: PageProps<"/s/[spaceSlug]/ite {low ? Low stock : null} {archived ? Archived : null} -
+
{stats.map((stat) => )} - +
- - + - + {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/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/app/styles/tokens.css b/app/styles/tokens.css index c9cd81f..7e023cd 100644 --- a/app/styles/tokens.css +++ b/app/styles/tokens.css @@ -5,7 +5,7 @@ * shadow, and motion value used by components comes from here; components * never hard-code hex values. * - * Source of truth: SpaceWorks-HQ/SpaceWorks-Design (`tokens/tokens.css`, + * Source of truth: SpaceWorks-Labs/SpaceWorks-Design (`tokens/tokens.css`, * `docs/ui-brand-spec.md`). The `--yy-` prefix matches Space Works Events so * utilities stay interchangeable between the two apps; the values are Space Works. * Every text/background pair below reaches WCAG AA (4.5:1); disabled text is 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/chrome/AppShell.tsx b/components/chrome/AppShell.tsx index 8b2fec1..49286eb 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 { ChevronsUpDown, LogOut, Moon, Sun } 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 }) { @@ -67,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 ( @@ -81,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 @@ -94,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"} @@ -119,8 +133,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} @@ -130,6 +144,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 +182,20 @@ function SidebarContents({ data, onNavigate, headerAction }: { data: ShellData; {headerAction ?? }
@@ -191,7 +232,7 @@ export function AppShell({ data, children }: { data: ShellData; children: ReactN - Menu + @@ -205,7 +246,7 @@ export function AppShell({ data, children }: { data: ShellData; children: ReactN headerAction={ - Close + } 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/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/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/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/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/providers.tsx b/components/providers.tsx index 24b1037..56d8301 100644 --- a/components/providers.tsx +++ b/components/providers.tsx @@ -94,11 +94,18 @@ function transitionColorMode(commit: () => void, origin?: ColorModeOrigin) { root.style.setProperty("--sw-reveal-y", `${y}px`); root.classList.add(plan === "reveal-soft" ? "sw-theme-reveal-soft" : "sw-theme-reveal"); 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"); }; - const transition = doc.startViewTransition(commit); + const transition = doc.startViewTransition(() => { + // 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). + // Added in the update, after the old mode was captured as it was. + root.classList.add("sw-theme-switching"); + commit(); + }); // Hold the final frame: without fill the mask snaps back to 0 for a frame // before the transition tears down, flashing the old mode. const timing: KeyframeAnimationOptions = { duration: REVEAL_DURATION_MS, easing: REVEAL_EASING, fill: "forwards", pseudoElement: "::view-transition-new(root)" }; 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 ? ( void) { + window.addEventListener("storage", onChange); + return () => window.removeEventListener("storage", onChange); +} + +function save(key: string, open: boolean) { + try { + 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. + * `id` names the kind of section (shared by every page that shows it); + * `anchor` is an optional element id for in-page links. + */ +export function CollapsibleCard({ + id, title, count, description, action, defaultOpen = true, titleAs: Title = "h2", anchor, className, children, +}: { + id: string; + title: string; + count?: number; + description?: ReactNode; + action?: ReactNode; + defaultOpen?: boolean; + titleAs?: "h2" | "h3"; + anchor?: string; + className?: string; + children: ReactNode; +}) { + const [open, toggle] = useRememberedOpen(`swi:dash:${id}`, defaultOpen); + const bodyId = useId(); + + 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} +
      + +
      + ); +} + +/** + * 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 ( +
      +
      +
      + + <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/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/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 e682fee..5d57de9 100644 --- a/components/ui/field.tsx +++ b/components/ui/field.tsx @@ -4,14 +4,16 @@ import { cloneElement, forwardRef, isValidElement, + useEffect, useId, + useRef, useState, type InputHTMLAttributes, type ReactElement, 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"; @@ -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 ( -
      +
      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..bf54fe0 100644 --- a/components/ui/table.tsx +++ b/components/ui/table.tsx @@ -1,12 +1,16 @@ 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. -
      - +
      + {/* Words never split letter by letter inside a cell (pages set overflow-wrap:anywhere); a tight table scrolls in its box instead. */} +
      {children}
      @@ -26,7 +30,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/CONFIGURATION.md b/docs/CONFIGURATION.md index 617dfd6..9766b04 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -123,7 +123,7 @@ These belong to the [VPS installer](DEPLOYMENT_VPS.md), not the application. | --- | --- | | `SWI_DIR` | Install root, `/opt/spaceworks-inventory` (`--dir`) | | `SWI_CONFIG_DIR` | External secrets and configuration directory, `/etc/spaceworks-inventory` | -| `SWI_REPOSITORY` | GitHub owner/name to install from (`--repo`); defaults to `SpaceWorks-HQ/SpaceWorks-Inventory` | +| `SWI_REPOSITORY` | GitHub owner/name to install from (`--repo`); defaults to `SpaceWorks-Labs/SpaceWorks-Inventory` | | `SWI_RELEASE_TAG`, `SWI_REF` | A published release or a source reference to build (`--release`, `--ref`); choose only one | | `SWI_GITHUB_TOKEN`, `SWI_GITHUB_USER` | Read credential for a private repository and its packages, and the account that owns it (see [the VPS guide](DEPLOYMENT_VPS.md#private-repository-and-packages)) | | `SWI_CADDY_TLS` | `internal` (LAN or trial), `proxy` (your own reverse proxy) or unset for automatic public HTTPS; recorded in `compose.env` | diff --git a/docs/CONTAINER_IMAGES.md b/docs/CONTAINER_IMAGES.md index b505904..feb0efb 100644 --- a/docs/CONTAINER_IMAGES.md +++ b/docs/CONTAINER_IMAGES.md @@ -15,8 +15,8 @@ roles: | Image | Dockerfile target | Roles (the container command) | | --- | --- | --- | -| `ghcr.io/spaceworks-hq/spaceworks-inventory-backend` | `runner` | `node server.js` (default): the whole application and API. `migrate`: apply pending database migrations once and exit. `node dist/worker.js`: the BullMQ worker (email and webhook delivery, reminders, count schedules, payment reconciliation, retention). `node dist/beat.js`: the scheduler that keeps the periodic jobs registered. `node docker/vps/.mjs` and `node scripts/.mjs`: the operator tools (VPS setup, SMTP and external-storage configuration, certificate renewal, super-admin recovery, production preflight, checks, SMTP check, smoke test), run as root. | -| `ghcr.io/spaceworks-hq/spaceworks-inventory-frontend` | `frontend` | `node frontend/server.js`: the website alone (the `frontend/` workspace), for the [split layouts](DEPLOYMENT_SPLIT.md). It holds no database, Redis, storage or mail credentials and calls the backend through the authenticated gateway. | +| `ghcr.io/spaceworks-labs/spaceworks-inventory-backend` | `runner` | `node server.js` (default): the whole application and API. `migrate`: apply pending database migrations once and exit. `node dist/worker.js`: the BullMQ worker (email and webhook delivery, reminders, count schedules, payment reconciliation, retention). `node dist/beat.js`: the scheduler that keeps the periodic jobs registered. `node docker/vps/.mjs` and `node scripts/.mjs`: the operator tools (VPS setup, SMTP and external-storage configuration, certificate renewal, super-admin recovery, production preflight, checks, SMTP check, smoke test), run as root. | +| `ghcr.io/spaceworks-labs/spaceworks-inventory-frontend` | `frontend` | `node frontend/server.js`: the website alone (the `frontend/` workspace), for the [split layouts](DEPLOYMENT_SPLIT.md). It holds no database, Redis, storage or mail credentials and calls the backend through the authenticated gateway. | A push to `dev` runs the same `Quality gate` inside the `Release` workflow and, when it passes, publishes the same two images as test builds tagged diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 121bc78..639c007 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -12,17 +12,17 @@ conservatively during the build and the owner may revisit them. Space Works is a family of self-hosted, open-source (AGPL-3.0-or-later) tools for makerspaces and communities under the -[SpaceWorks-HQ](https://github.com/SpaceWorks-HQ) organisation. On 2026-10-23 -three areas of the original [SpaceWorks](https://github.com/SpaceWorks-HQ/SpaceWorks) +[SpaceWorks-Labs](https://github.com/SpaceWorks-Labs) organisation. On 2026-10-23 +three areas of the original [SpaceWorks](https://github.com/SpaceWorks-Labs/SpaceWorks) (Django + React) app were split into standalone apps: -[Inventory](https://github.com/SpaceWorks-HQ/SpaceWorks-Inventory) (this repo: +[Inventory](https://github.com/SpaceWorks-Labs/SpaceWorks-Inventory) (this repo: stock room and lending desk), -[Machines](https://github.com/SpaceWorks-HQ/SpaceWorks-Machines) and -[Bookings](https://github.com/SpaceWorks-HQ/SpaceWorks-Bookings). -[SpaceWorks-Events](https://github.com/SpaceWorks-HQ/SpaceWorks-Events) and -[SpaceWorks-Workspace](https://github.com/SpaceWorks-HQ/SpaceWorks-Workspace) +[Machines](https://github.com/SpaceWorks-Labs/SpaceWorks-Machines) and +[Bookings](https://github.com/SpaceWorks-Labs/SpaceWorks-Bookings). +[SpaceWorks-Events](https://github.com/SpaceWorks-Labs/SpaceWorks-Events) and +[SpaceWorks-Workspace](https://github.com/SpaceWorks-Labs/SpaceWorks-Workspace) are the reference for structure, install and deploy; brand and UI rules come -only from [SpaceWorks-Design](https://spaceworks-hq.github.io/SpaceWorks-Design/). +only from [SpaceWorks-Design](https://spaceworks-labs.github.io/SpaceWorks-Design/). ## Locked at the split (2026-10-23) @@ -107,6 +107,20 @@ 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. +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/docs/DEPLOYMENT_CLOUD.md b/docs/DEPLOYMENT_CLOUD.md index e22a014..9a03244 100644 --- a/docs/DEPLOYMENT_CLOUD.md +++ b/docs/DEPLOYMENT_CLOUD.md @@ -20,14 +20,14 @@ complete stack through the [installer](DEPLOYMENT_VPS.md). ## One-click deploy on Render -[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/SpaceWorks-HQ/SpaceWorks-Inventory) +[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/SpaceWorks-Labs/SpaceWorks-Inventory) The button opens Render's Blueprint flow for -`https://github.com/SpaceWorks-HQ/SpaceWorks-Inventory` and reads `render.yaml` +`https://github.com/SpaceWorks-Labs/SpaceWorks-Inventory` and reads `render.yaml` from the default branch. **While the repository is private**, Render can only read it through GitHub: in Render, connect your GitHub account (**Account settings → Git providers**) and grant the Render GitHub app access to the -`SpaceWorks-HQ/SpaceWorks-Inventory` repository (or to your fork), then use +`SpaceWorks-Labs/SpaceWorks-Inventory` repository (or to your fork), then use **New → Blueprint** and pick the repository. The button alone fails for a repository Render cannot see. diff --git a/docs/DEPLOYMENT_DOCKER.md b/docs/DEPLOYMENT_DOCKER.md index a5551ba..08b858b 100644 --- a/docs/DEPLOYMENT_DOCKER.md +++ b/docs/DEPLOYMENT_DOCKER.md @@ -9,7 +9,7 @@ only a private container port. ## Images and runtime -Pull an immutable `ghcr.io/spaceworks-hq/spaceworks-inventory-backend:` +Pull an immutable `ghcr.io/spaceworks-labs/spaceworks-inventory-backend:` (while the packages are private, `docker login ghcr.io` first with a classic token that has `read:packages`). It serves the whole application by default. Run `migrate` once before the web rollout using an administration connection. @@ -18,7 +18,7 @@ Redis and custom S3 endpoints. ```bash export SWI_VERSION=0.1.0-main.42.a1b2c3d4e5f6 -image="ghcr.io/spaceworks-hq/spaceworks-inventory-backend:$SWI_VERSION" +image="ghcr.io/spaceworks-labs/spaceworks-inventory-backend:$SWI_VERSION" docker pull "$image" docker run --rm --env-file /secure/swi-migration.env "$image" migrate docker run -d --name spaceworks-inventory --restart unless-stopped \ diff --git a/docs/DEPLOYMENT_VPS.md b/docs/DEPLOYMENT_VPS.md index daf9171..d8631b9 100644 --- a/docs/DEPLOYMENT_VPS.md +++ b/docs/DEPLOYMENT_VPS.md @@ -48,7 +48,7 @@ server. As root or a user with `sudo`: ```bash -curl -fsSL https://raw.githubusercontent.com/SpaceWorks-HQ/SpaceWorks-Inventory/main/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/SpaceWorks-Labs/SpaceWorks-Inventory/main/install.sh | bash ``` While the repository is private, this public URL answers 404: use @@ -113,17 +113,17 @@ these work: ```bash # 1. GitHub CLI, if it is installed and signed in on the server: -gh api repos/SpaceWorks-HQ/SpaceWorks-Inventory/contents/install.sh \ +gh api repos/SpaceWorks-Labs/SpaceWorks-Inventory/contents/install.sh \ -H "Accept: application/vnd.github.raw" | bash # 2. curl, with the header read from stdin so the token stays out of the process list: printf 'header = "Authorization: Bearer %s"\n' "$SWI_GITHUB_TOKEN" | \ - curl --config - -fsSL https://raw.githubusercontent.com/SpaceWorks-HQ/SpaceWorks-Inventory/main/install.sh -o /tmp/swi-install.sh + curl --config - -fsSL https://raw.githubusercontent.com/SpaceWorks-Labs/SpaceWorks-Inventory/main/install.sh -o /tmp/swi-install.sh bash /tmp/swi-install.sh && rm -f /tmp/swi-install.sh # 3. The short form (the token is briefly visible to other users of this host in `ps`): curl -fsSL -H "Authorization: Bearer $SWI_GITHUB_TOKEN" \ - https://raw.githubusercontent.com/SpaceWorks-HQ/SpaceWorks-Inventory/main/install.sh | bash + https://raw.githubusercontent.com/SpaceWorks-Labs/SpaceWorks-Inventory/main/install.sh | bash ``` Because the token is exported, `bash` inherits it; there is no need to repeat @@ -288,7 +288,7 @@ Use a new version label for every build. Build images locally below, or use a trusted build host and transfer the same versioned images to the VPS. ```bash -git clone https://github.com/SpaceWorks-HQ/SpaceWorks-Inventory.git +git clone https://github.com/SpaceWorks-Labs/SpaceWorks-Inventory.git cd SpaceWorks-Inventory git checkout SWI_VERSION="vps-$(git rev-parse --short=12 HEAD)" @@ -532,10 +532,10 @@ backup=/root/
      \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/install.sh b/install.sh index df08b2e..80d3a87 100755 --- a/install.sh +++ b/install.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Space Works Inventory VPS installer: -# curl -fsSL https://raw.githubusercontent.com/SpaceWorks-HQ/SpaceWorks-Inventory/main/install.sh | bash +# curl -fsSL https://raw.githubusercontent.com/SpaceWorks-Labs/SpaceWorks-Inventory/main/install.sh | bash # Installs the latest GitHub Release (or --release TAG) with its published # container images, or builds a branch or commit from source with --ref. It # downloads that source into temporary storage and runs its guided setup, which @@ -39,7 +39,7 @@ What it does Options --release TAG install or update to this release (default: the latest release) --ref REF build a branch, tag or commit from source instead of a release - --repo O/N install from a fork (default: SpaceWorks-HQ/SpaceWorks-Inventory) + --repo O/N install from a fork (default: SpaceWorks-Labs/SpaceWorks-Inventory) --dir PATH installation directory (default: /opt/spaceworks-inventory) --update update an existing installation without the menu --help show this guide and exit @@ -147,9 +147,9 @@ main() { if [[ -z "$repository" && -z "$installed" && -z "$pending" && "$action" != update ]]; then repository=$(prompt " Install from which GitHub repository? Press Enter for the official one. -Repository [SpaceWorks-HQ/SpaceWorks-Inventory]: ") +Repository [SpaceWorks-Labs/SpaceWorks-Inventory]: ") fi - repository=${repository:-SpaceWorks-HQ/SpaceWorks-Inventory} + repository=${repository:-SpaceWorks-Labs/SpaceWorks-Inventory} [[ "$repository" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,37}[A-Za-z0-9])?/[A-Za-z0-9_.-]{1,100}$ && "$repository" != */. && "$repository" != */.. ]] \ || die 'The repository must be a GitHub owner/name.' RUN_ENV=(env "SWI_DIR=$install_dir" "SWI_REPOSITORY=$repository") diff --git a/scripts/capture-readme-screens.mjs b/scripts/capture-readme-screens.mjs index 2bebe34..583c548 100644 --- a/scripts/capture-readme-screens.mjs +++ b/scripts/capture-readme-screens.mjs @@ -21,12 +21,12 @@ const space = "/s/tinkerspace-kochi"; const SCREENS = [ ["home", "joel", space], ["loans", "joel", `${space}/loans`], - ["loan-issue", "joel", async (page) => firstLink(page, `${space}/loans?tab=ready`, `${space}/loans/`)], + ["loan-issue", "joel", async (page) => firstLink(page, `${space}/loans?tab=ready`, `${space}/loans/c`)], ["items", "meera", `${space}/items`], - ["item", "meera", async (page) => firstLink(page, `${space}/items?q=Multimeter`, `${space}/items/`)], + ["item", "meera", async (page) => firstLink(page, `${space}/items?q=Multimeter`, `${space}/items/c`)], ["stock", "meera", `${space}/stock`], ["locations", "meera", `${space}/locations`], - ["transfer", "meera", async (page) => firstLink(page, `${space}/transfers`, `${space}/transfers/`)], + ["transfer", "meera", async (page) => firstLink(page, `${space}/transfers`, `${space}/transfers/c`)], ["counts", "meera", `${space}/counts`], ["reports", "meera", `${space}/reports`], ["repairs", "meera", `${space}/repairs`], diff --git a/scripts/vps-lib.sh b/scripts/vps-lib.sh index b55923b..eb6f7dd 100755 --- a/scripts/vps-lib.sh +++ b/scripts/vps-lib.sh @@ -6,7 +6,7 @@ SWI_DIR=${SWI_DIR:-/opt/spaceworks-inventory} SWI_DEFAULT_CONFIG_DIR=/etc/spaceworks-inventory # shellcheck disable=SC2034 # used by the scripts that source this file -SWI_DEFAULT_REPOSITORY=SpaceWorks-HQ/SpaceWorks-Inventory +SWI_DEFAULT_REPOSITORY=SpaceWorks-Labs/SpaceWorks-Inventory say() { printf '[Space Works Inventory] %s\n' "$*"; } warn() { printf '[Space Works Inventory] WARNING: %s\n' "$*" >&2; } 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/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 1d72974..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"]); + 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) { diff --git a/tests/unit/vps-installer-configuration.test.ts b/tests/unit/vps-installer-configuration.test.ts index cc03f49..0d38695 100644 --- a/tests/unit/vps-installer-configuration.test.ts +++ b/tests/unit/vps-installer-configuration.test.ts @@ -23,7 +23,7 @@ describe("VPS installer configuration", () => { expect(check("is_safe_path", "/etc/spaceworks-inventory")).toBe(true); expect(check("is_safe_path", "/etc/space works")).toBe(false); expect(check("is_safe_path", "relative")).toBe(false); - expect(check("is_repository", "SpaceWorks-HQ/SpaceWorks-Inventory")).toBe(true); + expect(check("is_repository", "SpaceWorks-Labs/SpaceWorks-Inventory")).toBe(true); expect(check("is_repository", "owner/..")).toBe(false); expect(lib("release_version 0123456789abcdef0123456789abcdef01234567").stdout).toBe("vps-0123456789ab"); }); diff --git a/tests/unit/vps-installer.test.ts b/tests/unit/vps-installer.test.ts index 8ddf38c..220aaca 100644 --- a/tests/unit/vps-installer.test.ts +++ b/tests/unit/vps-installer.test.ts @@ -46,14 +46,14 @@ describe("VPS installer", () => { expect(lib(`source_version '${dir}' ${commit}`).stdout).toBe("0.1.0-main.42.0123456789ab"); expect(lib(`source_version '${dir}' ffffffffffff${commit.slice(12)}`).status).not.toBe(0); const settings = (version: string) => lib( - `set_image_settings '${dir}/compose.env' ${version} SpaceWorks-HQ/SpaceWorks-Inventory; cat '${dir}/compose.env'`, + `set_image_settings '${dir}/compose.env' ${version} SpaceWorks-Labs/SpaceWorks-Inventory; cat '${dir}/compose.env'`, ).stdout; // A 0.9.0 installation's separate migrator/ops image settings are dropped. writeFileSync(path.join(directory, "compose.env"), "SWI_VERSION='0.9.0-main.1.0123456789ab'\nSWI_APP_IMAGE='x'\nSWI_MIGRATOR_IMAGE='y'\nSWI_OPS_IMAGE='z'\n"); expect(settings("0.1.0-main.42.0123456789ab")).toBe([ "SWI_VERSION='0.9.0-main.1.0123456789ab'", - "SWI_BACKEND_IMAGE='ghcr.io/spaceworks-hq/spaceworks-inventory-backend'", - "SWI_FRONTEND_IMAGE='ghcr.io/spaceworks-hq/spaceworks-inventory-frontend'", "", + "SWI_BACKEND_IMAGE='ghcr.io/spaceworks-labs/spaceworks-inventory-backend'", + "SWI_FRONTEND_IMAGE='ghcr.io/spaceworks-labs/spaceworks-inventory-frontend'", "", ].join("\n")); expect(settings("vps-0123456789ab")).toBe("SWI_VERSION='0.9.0-main.1.0123456789ab'\nSWI_BACKEND_IMAGE=''\nSWI_FRONTEND_IMAGE=''\n"); } finally { rmSync(directory, { recursive: true, force: true }); }