ThreadCount Community edition

Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from a113353 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-15 22:54:09 +10:00
commit a6f1059ddf
424 changed files with 53535 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
.next
.photos
.git
.claude
docs
# The manual is read by the app at request time (Help), so it has to be in the image.
!docs/manual
patches/*.orig
.env
.env.*
!.env.example
+52
View File
@@ -0,0 +1,52 @@
# Every variable the Community edition reads. docker-compose.yml passes this file to the app and
# fills DATABASE_URL, PORT and PHOTO_DIR itself.
#
# The variables beginning NEXT_PUBLIC_ are compiled into the browser bundle at build time, not read
# at runtime — set them before the first `docker compose up --build`, and rebuild if they change.
# ---- required ----
# Signs every session cookie. Generate one per instance (`openssl rand -base64 48`). The server
# refuses to start while it still says change-me.
SESSION_SECRET=change-me
# The bundled database's password (docker-compose.yml only).
POSTGRES_PASSWORD=
# The address people open the app at, e.g. https://uniforms.example.health. Used in emailed links.
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# Always "community" on your own server.
EDITION=community
# ---- your documents ----
# The staff sign-in and the account screens link to a terms page and a privacy notice. Point them
# at your own; until you do they point at threadcount.tech's, which describe the hosted service.
NEXT_PUBLIC_TERMS_URL=
NEXT_PUBLIC_PRIVACY_URL=
# ---- transactional mail (optional) ----
# With these unset nothing is sent: password resets and approval links are handled at the counter,
# and the screens say so rather than claiming otherwise.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM="ThreadCount <no-reply@example.health>"
# ---- optional ----
# 1 hides the create-account form and refuses the sign-up endpoint. Set it once your facility exists.
SIGNUPS_DISABLED=
# Cloudflare Turnstile on sign-in and sign-up. Set both to enforce it; leave both blank to rely on
# the per-address rate limits alone.
TURNSTILE_SECRET=
NEXT_PUBLIC_TURNSTILE_SITEKEY=
# Your own GlitchTip (Sentry-protocol) DSN for error reports. Blank = nothing is reported anywhere.
NEXT_PUBLIC_GLITCHTIP_DSN=
# Stamped on error reports so a fault can be tied to a build.
NEXT_PUBLIC_RELEASE=
# Host port docker-compose.yml publishes the app on (the container always listens on 3000).
APP_PORT=3000
# Your own Umami, if any: the tracker script address and the site ids. Blank = no statistics sent.
NEXT_PUBLIC_UMAMI_SRC=
NEXT_PUBLIC_UMAMI_SITE_ID=
NEXT_PUBLIC_UMAMI_APP_ID=
# Your own Chatwoot, if any: a chat widget on the coordinator app. Blank = no widget.
NEXT_PUBLIC_CHATWOOT_URL=
NEXT_PUBLIC_CHATWOOT_TOKEN=
+83
View File
@@ -0,0 +1,83 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# the hosted deploy hardlinks the running build and node_modules in here so a failed deploy can be
# undone, and deletes it again when the deploy ends. While one is in flight it is ~100k untracked
# files sitting in the work tree, which would otherwise drown `git status` on the production box.
/.deploy-prev/
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# written by the hosted deploy on the box
.release.json
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env
.env.*
!.env.example
# Android / Capacitor build output. The project itself is committed; its build products are not.
android/.gradle/
android/build/
android/app/build/
android/capacitor-cordova-android-plugins/build/
android/local.properties
android/app/src/main/assets/public/
# The staff app's native project. Same rules: sources are committed, build output and the
# capacitor-copied web assets are not.
android-staff/.gradle/
android-staff/build/
android-staff/app/build/
android-staff/capacitor-cordova-android-plugins/build/
android-staff/local.properties
android-staff/app/src/main/assets/public/
*.aab
*.apk
*.jks
*.keystore
keystore.properties
# Photo storage: signatures and damage photographs live on disk, not in the repo or the database.
.photos/
# session scratch, never committed
.scratch/
# scratch trees made by the community export
/.community-build.*
.next-build/
.next-swap/
+1
View File
@@ -0,0 +1 @@
community 2026-09-15 a113353
+50
View File
@@ -0,0 +1,50 @@
# ThreadCount, Community edition — one image, built where it runs.
#
# Built locally by `docker compose build` rather than pulled, and for a reason: everything that
# starts NEXT_PUBLIC_ is compiled into the browser bundle, so the address people will open the app
# at (NEXT_PUBLIC_SITE_URL) and the optional Turnstile site key have to be known at build time.
# docker-compose.yml passes them in from your .env as build arguments.
#
# Three stages:
# deps — node_modules from the lockfile (patch-package runs in postinstall).
# builder — the Prisma client and the Next build. Also the image `docker compose run migrate`
# uses, because it still has the Prisma CLI.
# runner — Next's standalone output only: no compiler, no CLI, a non-root user.
FROM node:24-bookworm-slim AS deps
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
COPY patches ./patches
RUN npm ci --ignore-scripts && npx patch-package
FROM deps AS builder
WORKDIR /app
COPY . .
ARG NEXT_PUBLIC_SITE_URL=http://localhost:3000
ARG NEXT_PUBLIC_TURNSTILE_SITEKEY=
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL \
NEXT_PUBLIC_TURNSTILE_SITEKEY=$NEXT_PUBLIC_TURNSTILE_SITEKEY \
EDITION=community \
NEXT_OUTPUT=standalone \
NEXT_TELEMETRY_DISABLED=1
RUN npx prisma generate && npm run build
FROM node:24-bookworm-slim AS runner
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates curl && rm -rf /var/lib/apt/lists/* \
&& groupadd -r threadcount && useradd -r -g threadcount -d /app threadcount \
&& mkdir -p /data/photos && chown -R threadcount:threadcount /data
ENV NODE_ENV=production \
EDITION=community \
PHOTO_DIR=/data/photos \
PORT=3000 \
HOSTNAME=0.0.0.0 \
NEXT_TELEMETRY_DISABLED=1
COPY --from=builder --chown=threadcount:threadcount /app/.next/standalone ./
COPY --from=builder --chown=threadcount:threadcount /app/.next/static ./.next/static
COPY --from=builder --chown=threadcount:threadcount /app/public ./public
USER threadcount
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl -fsS http://127.0.0.1:3000/api/health || exit 1
CMD ["node", "server.js"]
+105
View File
@@ -0,0 +1,105 @@
# Functional Source License, Version 1.1, ALv2 Future License
## Abbreviation
FSL-1.1-ALv2
## Notice
Copyright 2026 ThreadCount (threadcount.tech)
## Terms and Conditions
### Licensor ("We")
The party offering the Software under these Terms and Conditions.
### The Software
The "Software" is each version of the software that we make available under
these Terms and Conditions, as indicated by our inclusion of these Terms and
Conditions with the Software.
### License Grant
Subject to your compliance with this License Grant and the Patents,
Redistribution and Trademark clauses below, we hereby grant you the right to
use, copy, modify, create derivative works, publicly perform, publicly display
and redistribute the Software for any Permitted Purpose identified below.
### Permitted Purpose
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
means making the Software available to others in a commercial product or
service that:
1. substitutes for the Software;
2. substitutes for any other product or service we offer using the Software
that exists as of the date we make the Software available; or
3. offers the same or substantially similar functionality as the Software.
Permitted Purposes specifically include using the Software:
1. for your internal use and access;
2. for non-commercial education;
3. for non-commercial research; and
4. in connection with professional services that you provide to a licensee
using the Software in accordance with these Terms and Conditions.
### Patents
To the extent your use for a Permitted Purpose would necessarily infringe our
patents, the license grant above includes a license under our patents. If you
make a claim against any party that the Software infringes or contributes to
the infringement of any patent, then your patent license to the Software ends
immediately.
### Redistribution
The Terms and Conditions apply to all copies, modifications and derivatives of
the Software.
If you redistribute any copies, modifications or derivatives of the Software,
you must include a copy of or a link to these Terms and Conditions and not
remove any copyright notices provided in or with the Software.
### Disclaimer
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
### Trademarks
Except for displaying the License Details and identifying us as the origin of
the Software, you have no right under these Terms and Conditions to use our
trademarks, trade names, service marks or product names.
## Grant of Future License
We hereby irrevocably grant you an additional license to use the Software under
the Apache License, Version 2.0 that is effective on the second anniversary of
the date we make the Software available. On or after that date, you may use the
Software under the Apache License, Version 2.0, in which case the following
will apply:
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.
+157
View File
@@ -0,0 +1,157 @@
<p align="center"><img src="docs/banner.svg" alt="ThreadCount" width="100%"></p>
# ThreadCount
Uniform stock management for hospital, aged-care and clinic linen rooms. It tracks what is on the
shelf, who was issued what, what each ward was charged, and the supplier orders and stocktakes in
between. It was written by a uniform coordinator for their own linen room.
This is the Community edition: the same product that runs at [threadcount.tech](https://threadcount.tech),
packaged for a facility or health service that wants to run it on its own server. Every feature a
room uses is in it. Install takes about five minutes on a server that already has Docker; the
steps are below, and the longer guide is [docs/self-hosting.md](docs/self-hosting.md).
## What you get
- **The coordinator app** at `/app`: catalogue, stock, issuing against entitlement, orders and
receiving, stocktakes, nine reports, CSV import and export, full JSON backup and restore.
- **The phone counter** at `/m`: camera barcode scanning, issue at the counter, count by shelf,
pickups and delivery rounds.
- **The staff app** at `/my`: staff see their own kit, request garments, managers approve.
- The two Android apps on Google Play can be pointed at your server (see below).
Not included: the threadcount.tech website, multi-site health-service features, single sign-on,
and card payments. Those belong to the hosted service.
## Or let us host it
If you would rather not run a server, the hosted service is the same product with backups kept
for 35 days, a 99.5% availability target, support response times in writing, and card or invoice
billing. A room under 60 staff records is hosted free; a facility is $129 a month or $1,290 a
year after a 30-day trial with no card. See [threadcount.tech/pricing](https://threadcount.tech/pricing)
and the [Service Level Agreement](https://threadcount.tech/sla).
## Requirements
- A Linux server with Docker and Docker Compose (2 CPU, 2 GB RAM is plenty to start).
- A hostname pointing at it, with HTTPS in front (Caddy, nginx or Traefik). The app sets secure
cookies, so sign-in will not work over plain HTTP from another machine.
## Install
```sh
git clone https://github.com/pricehq/threadcount-community.git
cd threadcount-community
cp .env.example .env
```
Open `.env` and set these four. Everything else can stay blank.
| Setting | What to put |
|---|---|
| `SESSION_SECRET` | A long random string. `openssl rand -base64 48` makes one. The server refuses to start with the placeholder. |
| `POSTGRES_PASSWORD` | Any long password. It is only used between the two containers. |
| `NEXT_PUBLIC_SITE_URL` | The address people will type, for example `https://uniforms.example.health`. |
| `EDITION` | `community` |
Then build and start it:
```sh
docker compose up -d --build
```
The first start takes a few minutes: it builds the image, creates the database and applies the
schema. When `docker compose ps` shows the `app` container as healthy, it is ready.
By default the app listens on **port 3000** on the server (change it with `APP_PORT` in `.env`).
Point your HTTPS proxy at it. A Caddyfile for that is two lines:
```
uniforms.example.health {
reverse_proxy 127.0.0.1:3000
}
```
## First run
There is no default username or password. The first person to sign up creates the facility and
becomes its administrator.
1. Open your address in a browser. `/` sends you to `/auth`, the sign-in page.
2. Click **Create account**. Enter your name, the facility name, your email and a password.
3. You are now signed in as the facility's admin and land on the dashboard.
4. Go to **Settings**. Name your staff groups first (for example Registered Nurse, Enrolled Nurse,
Support Services). Nothing can be issued until a facility has groups.
5. Still in Settings, open **Data** and load your catalogue, departments, staff register and
opening stock from CSV. Templates for each file are on that screen.
6. Add a second administrator under **Settings → Account → Users** before you sign out. If the
only admin forgets their password and no email is configured, nobody can get back in.
7. Once your facility exists, set `SIGNUPS_DISABLED=1` in `.env` and run `docker compose up -d`
again. Nobody else can create a facility on your server after that.
Admins and issuers are both created under Settings → Account → Users. An admin can do everything;
an issuer works the counter but cannot change settings, reorder levels or barcodes.
## Phones and the Android apps
The phone counter and the staff app are the same server, on a phone:
- `https://your-host/m` for the counter (camera scanning works in Chrome and Edge)
- `https://your-host/my` for staff
The Play apps (**ThreadCount** for the counter, **ThreadCount Staff** for staff) can use your server
too. On the app's first screen tap "Server: threadcount.tech · Change", choose Self-hosted and enter
your hostname. The app checks it, saves it on that phone, and opens your server from then on.
## Email
Set the `SMTP_*` values in `.env` if you want password resets, manager approval links and
ready-to-collect notices by email. Without them the product still works; those things happen at
the counter, and the screens say so.
## Backups
`docker/backup.sh /path/to/backups` dumps the database and the photos into a dated folder and
keeps the last fourteen. Run it nightly from cron and copy the folder off the server. An admin can
also download the whole facility as one file from Settings → Data at any time.
Restore steps are in [docs/self-hosting.md](docs/self-hosting.md).
## Updating
Each release replaces the repository's history rather than adding to it, so a plain `git pull`
refuses to merge. Fetch and move to the release instead. Your `.env` is not tracked and stays put.
```sh
git fetch origin
git reset --hard origin/main
docker compose up -d --build
```
Schema changes are applied automatically before the new version starts.
## Configuration
Every setting is listed with a comment in `.env.example`. The ones most people touch:
`NEXT_PUBLIC_TERMS_URL` and `NEXT_PUBLIC_PRIVACY_URL` (point the product's terms and privacy links
at your own documents), `SMTP_*`, `SIGNUPS_DISABLED`, `APP_PORT`, and the two Turnstile keys if you
want Cloudflare's bot check on sign-in.
## Licence
Functional Source License 1.1 with Apache 2.0 as the future licence (FSL-1.1-ALv2). You can run
it for your own organisation, read it, change it and share your changes. You cannot offer it to
others as a competing uniform-management service. Each release becomes Apache 2.0 two years after
publication. Full text in [LICENSE](LICENSE).
## Security
Found a vulnerability? Email security@threadcount.tech rather than opening a public issue. The
[security page](https://threadcount.tech/security) describes how the hosted service is run and
what the questionnaire answers; a Community instance inherits the same code and the practices in
[docs/self-hosting.md](docs/self-hosting.md) are the ones that matter for yours.
## Help
Open an issue on this repository. If you would rather not run a server at all, the hosted service
is at [threadcount.tech](https://threadcount.tech).
+121
View File
@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import QRCode from "qrcode";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/session";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import {
decryptSecret, encryptSecret, hashRecoveryCode, newRecoveryCodes, newTotpSecret, otpauthUrl, totpVerify,
} from "@/lib/totp";
import { recordAuthEvent } from "@/lib/audit";
export const dynamic = "force-dynamic";
/* Turning a second factor on and off, for your own account only.
*
* Three steps rather than one, because a secret that is stored the moment it is generated leaves
* an account half-enrolled if the person never finishes — and then their next sign-in asks for
* codes from an app they never set up.
*
* setup — generate a secret and show the QR. Stored, but not yet in force.
* enable — prove a code from it works, then switch it on and hand back recovery codes.
* disable — password required, because turning a factor off is a privileged act.
*/
export async function GET() {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const u = await prisma.user.findUnique({ where: { id: user.id }, select: { totpEnabledAt: true } });
const left = await prisma.recoveryCode.count({ where: { userId: user.id, usedAt: null } });
return NextResponse.json({ enabled: !!u?.totpEnabledAt, enabledAt: u?.totpEnabledAt ?? null, recoveryLeft: left });
}
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const ip = clientIp(req.headers);
if (!allow("2fa-manage:" + user.id, 30, 15 * 60 * 1000)) {
return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 });
}
// Turning a second factor on or off is one of the few changes to an account that leaves no trace
// in the records themselves, so it is one of the few worth recording on its own.
const actor = {
facilityId: user.facilityId, userId: user.id,
userName: `${user.first} ${user.last}`.trim() || user.email,
};
let body: { action?: unknown; code?: unknown; password?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const action = String(body.action ?? "");
const u = await prisma.user.findUnique({
where: { id: user.id },
select: { id: true, email: true, passwordHash: true, totpSecret: true, totpEnabledAt: true },
});
if (!u) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
if (action === "setup") {
if (u.totpEnabledAt) return NextResponse.json({ error: "Two-factor is already on for this account." }, { status: 400 });
const secret = newTotpSecret();
await prisma.user.update({ where: { id: u.id }, data: { totpSecret: encryptSecret(secret) } });
const url = otpauthUrl(secret, u.email);
// SVG, generated here rather than in the browser: it keeps a QR library out of the bundle that
// ward phones download, and the secret never has to be handed to client-side code to render.
const qr = await QRCode.toString(url, { type: "svg", margin: 1, width: 220, errorCorrectionLevel: "M" });
recordAuthEvent(actor, "2fa:setup", ip);
return NextResponse.json({ ok: true, secret, url, qr });
}
if (action === "enable") {
if (u.totpEnabledAt) return NextResponse.json({ error: "Two-factor is already on." }, { status: 400 });
const secret = decryptSecret(u.totpSecret);
if (!secret) return NextResponse.json({ error: "Start the setup again." }, { status: 400 });
if (!totpVerify(secret, String(body.code ?? ""))) {
return NextResponse.json({ error: "That code isn't right. Use the current one from your app." }, { status: 400 });
}
const codes = newRecoveryCodes();
await prisma.$transaction(async (tx) => {
await tx.user.update({ where: { id: u.id }, data: { totpEnabledAt: new Date() } });
await tx.recoveryCode.deleteMany({ where: { userId: u.id } });
await tx.recoveryCode.createMany({ data: codes.map((c) => ({ userId: u.id, codeHash: hashRecoveryCode(c) })) });
});
recordAuthEvent(actor, "2fa:enable", ip);
// The only time these are ever readable. They are stored hashed, so there is no second chance.
return NextResponse.json({ ok: true, codes });
}
if (action === "disable") {
if (!u.totpEnabledAt) return NextResponse.json({ ok: true });
const pw = String(body.password ?? "");
if (!pw || !(await bcrypt.compare(pw, u.passwordHash))) {
return NextResponse.json({ error: "That password isn't right." }, { status: 401 });
}
await prisma.$transaction(async (tx) => {
await tx.user.update({ where: { id: u.id }, data: { totpEnabledAt: null, totpSecret: "" } });
await tx.recoveryCode.deleteMany({ where: { userId: u.id } });
});
recordAuthEvent(actor, "2fa:disable", ip);
return NextResponse.json({ ok: true });
}
if (action === "regenerate") {
if (!u.totpEnabledAt) return NextResponse.json({ error: "Two-factor isn't on." }, { status: 400 });
const pw = String(body.password ?? "");
if (!pw || !(await bcrypt.compare(pw, u.passwordHash))) {
return NextResponse.json({ error: "That password isn't right." }, { status: 401 });
}
const codes = newRecoveryCodes();
await prisma.$transaction(async (tx) => {
await tx.recoveryCode.deleteMany({ where: { userId: u.id } });
await tx.recoveryCode.createMany({ data: codes.map((c) => ({ userId: u.id, codeHash: hashRecoveryCode(c) })) });
});
recordAuthEvent(actor, "2fa:regenerate", ip);
return NextResponse.json({ ok: true, codes });
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
}
+58
View File
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/session";
export const dynamic = "force-dynamic";
const PAGE = 100;
/* The audit trail, read back.
*
* Admin only, and scoped to the caller's own facility by the query rather than by a filter the
* client sends — the client never gets to say which facility it wants. Paged by cursor rather
* than offset so a busy room's log doesn't shift under you as new rows land while you read.
*
* The cursor is (timestamp, id), not timestamp alone. Prisma stores DateTime at millisecond
* precision, and two events sharing a millisecond is ordinary rather than exotic — two coordinators
* saving at once, or two ops committed inside one transaction. A strict `at < cursor` dropped every
* row that shared the last one's millisecond, so the log looked complete with an event missing from
* it, which is the one failure an audit trail cannot have.
*/
/** `<iso>|<id>` — one opaque string, because the client only ever hands it straight back. */
function readCursor(raw: string | null): { at: Date; id: string } | null {
if (!raw) return null;
const cut = raw.lastIndexOf("|");
const iso = cut === -1 ? raw : raw.slice(0, cut);
const id = cut === -1 ? "" : raw.slice(cut + 1);
if (Number.isNaN(Date.parse(iso))) return null;
return { at: new Date(iso), id: id.slice(0, 40) };
}
export async function GET(req: NextRequest) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
// SessionUser.role is the database enum ("ADMIN"), not the snapshot's display form ("Admin").
if (user.role !== "ADMIN") return NextResponse.json({ error: "Admin only" }, { status: 403 });
const cursor = readCursor(req.nextUrl.searchParams.get("before"));
const rows = await prisma.auditEvent.findMany({
where: {
facilityId: user.facilityId,
// Everything strictly older, plus the rest of the millisecond we stopped in the middle of.
...(cursor ? { OR: [{ at: { lt: cursor.at } }, { at: cursor.at, id: { lt: cursor.id } }] } : {}),
},
orderBy: [{ at: "desc" }, { id: "desc" }],
take: PAGE + 1,
select: { id: true, at: true, userName: true, op: true, target: true },
});
const more = rows.length > PAGE;
const page = rows.slice(0, PAGE);
const last = page[page.length - 1];
return NextResponse.json({
events: page.map((r) => ({ id: r.id, at: r.at.toISOString(), who: r.userName, op: r.op, target: r.target })),
nextBefore: more && last ? `${last.at.toISOString()}|${last.id}` : null,
});
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { readFileSync } from "fs";
import path from "path";
import { COMMUNITY } from "@/lib/edition";
export const dynamic = "force-dynamic";
/* What the Android apps ask a server before they will point at it.
*
* The apps open threadcount.tech unless told otherwise; a room running the Community edition types
* its own address into the app's first screen, and the app calls this first. It proves the address
* is a ThreadCount server (not a look-alike, not a typo), says which edition and build, and carries
* the oldest app version this build still works with, so an app can say "update me" instead of
* breaking quietly. Public and unauthenticated on purpose: nothing here is about a facility. */
function version(): string {
try { return readFileSync(path.join(process.cwd(), "COMMUNITY_VERSION"), "utf8").trim(); } catch { /* hosted: no file */ }
return process.env.NEXT_PUBLIC_RELEASE || "hosted";
}
export async function GET() {
return NextResponse.json({
product: "threadcount",
edition: COMMUNITY ? "community" : "hosted",
version: version(),
paths: { counter: "/m", staff: "/my" },
// The oldest Play versionCode of each app this server still serves correctly.
minApp: { counter: 9, staff: 7 },
}, { headers: { "cache-control": "no-store", "access-control-allow-origin": "*" } });
}
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { cookies } from "next/headers";
import { REMEMBER_MAX_AGE, pwVersion, setSessionCookie } from "@/lib/session";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { decryptSecret, hashRecoveryCode, totpVerify } from "@/lib/totp";
import { TRUST_COOKIE, TRUST_TTL_MS, mintTrust, readTicket } from "@/lib/twofactor";
import { recordAuthEvent } from "@/lib/audit";
export const dynamic = "force-dynamic";
/* Second step of sign-in: the code from the authenticator, or one recovery code.
*
* Rate limited hard. A six-digit code is one in a million per guess, which is only meaningful if
* guessing is expensive — unthrottled, a million tries is minutes of work. */
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const ip = clientIp(req.headers);
let body: { ticket?: unknown; code?: unknown; trust?: unknown; remember?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const t = readTicket(String(body.ticket ?? ""));
if (!t) return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 });
// Per account and per address: one stolen ticket can't be brute-forced, and one machine can't
// work through several accounts at once.
if (!allow("2fa-user:" + t.uid, 10, 15 * 60 * 1000) || !allow("2fa-ip:" + ip, 300, 15 * 60 * 1000)) {
return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 });
}
const u = await prisma.user.findUnique({
where: { id: t.uid },
select: { id: true, facilityId: true, email: true, first: true, last: true, role: true, inactive: true, passwordHash: true, totpSecret: true, totpEnabledAt: true },
});
if (!u || u.inactive || !u.totpEnabledAt) {
return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 });
}
// The password changed between the two steps — the ticket is stale for the same reason a session
// would be.
if (pwVersion(u.passwordHash) !== t.pv) {
return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 });
}
const raw = String(body.code ?? "").trim();
const secret = decryptSecret(u.totpSecret);
let good = !!secret && totpVerify(secret, raw);
let usedRecovery = false;
if (!good && raw.replace(/[^A-Za-z0-9]/g, "").length >= 10) {
// A recovery code. Single use: consumed in the same conditional update that finds it, so two
// simultaneous attempts can't both spend it.
const hash = hashRecoveryCode(raw);
const hit = await prisma.recoveryCode.findFirst({ where: { userId: u.id, codeHash: hash, usedAt: null }, select: { id: true } });
if (hit) {
const consumed = await prisma.recoveryCode.updateMany({ where: { id: hit.id, usedAt: null }, data: { usedAt: new Date() } });
good = consumed.count === 1;
usedRecovery = good;
}
}
if (!good) return NextResponse.json({ error: "That code isn't right. Try the current one from your app." }, { status: 401 });
await setSessionCookie(u.id, u.passwordHash, false, body.remember === true ? REMEMBER_MAX_AGE : undefined);
// "Trust this computer": only ever set here, after a real code, never from the password step.
if (body.trust === true) {
const jar = await cookies();
jar.set(TRUST_COOKIE, mintTrust(u.id, pwVersion(u.passwordHash)), { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/api/auth", maxAge: Math.floor(TRUST_TTL_MS / 1000) });
}
// How they got in matters more here than anywhere else: a recovery code means the phone is gone,
// and a run of them means something else is going on.
recordAuthEvent(
{ facilityId: u.facilityId, userId: u.id, userName: `${u.first} ${u.last}`.trim() || u.email },
"auth:signin", ip, usedRecovery ? "recovery" : "totp",
);
const left = await prisma.recoveryCode.count({ where: { userId: u.id, usedAt: null } });
return NextResponse.json({ ok: true, name: `${u.first} ${u.last}`, role: u.role, usedRecovery, recoveryLeft: left });
}
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { allow, clientIp, fail, over } from "@/lib/ratelimit";
import { sameOriginJson } from "@/lib/csrf";
import { verifyTurnstile } from "@/lib/turnstile";
import { sendTo, transactionalConfigured } from "@/lib/mail";
import { RESET_TTL_MS, newResetToken, resetEmail, resetUrl } from "@/lib/reset";
export const dynamic = "force-dynamic";
/* Request a password reset.
*
* Before this existed a facility whose only admin forgot their password was locked out for good —
* the sign-in screen told them to ask an admin, and they were the admin. Deleting the last admin
* deletes the whole facility, so there was no way back in at all.
*
* The response is identical whether or not the address has an account. Anything else turns this
* into a way to ask "does this hospital use ThreadCount, and is this person a coordinator there?"
*/
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const ip = clientIp(req.headers);
let body: { email?: unknown; cfToken?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160);
// A slow ceiling per IP, so one machine can't walk a staff list to find out which addresses exist
// by watching how long each request takes. The per-address ceiling deliberately lives further
// down, past the bot check — see the note beside it.
if (!allow("forgot-ip:" + ip, 60, 60 * 60 * 1000)) {
return NextResponse.json({ ok: true });
}
const cfErr = await verifyTurnstile(body.cfToken, ip);
if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ ok: true });
const user = await prisma.user.findUnique({ where: { email }, select: { id: true, first: true, inactive: true, ssoBreakGlass: true, facility: { select: { ssoRequired: true } } } });
// A facility that requires single sign-on has no password door for most of its people, so a
// reset link would be a way round its identity provider. Break-glass admins keep theirs. The
// answer to the caller is the same either way.
const ssoOnly = !!user && user.facility.ssoRequired && !user.ssoBreakGlass;
if (user && !user.inactive && !ssoOnly) {
// The per-address ceiling counts mail actually sent, not requests received, and it is only
// consulted once the bot check has passed. Spent on requests, it handed a stranger a way to
// hold a facility's only coordinator out of their own account: four anonymous posts with no
// Turnstile token filled the bucket, and every later attempt by the coordinator was answered
// with "a reset link is on its way" and no mail. Counted this way the only way to exhaust the
// budget is to have four reset mails delivered to that same inbox, so whoever forgot their
// password always has a working link waiting for them.
const mailKey = "forgot-email:" + email;
if (over(mailKey, 4, 60 * 60 * 1000)) {
console.warn("[forgot] four reset mails already sent this hour — suppressing another for user", user.id);
} else {
const { token, tokenHash } = newResetToken();
await prisma.$transaction(async (tx) => {
// Asking again supersedes anything outstanding, so a forwarded older email goes dead.
await tx.passwordReset.updateMany({
where: { userId: user.id, usedAt: null },
data: { usedAt: new Date() },
});
await tx.passwordReset.create({
data: { userId: user.id, tokenHash, expiresAt: new Date(Date.now() + RESET_TTL_MS), requestIp: ip },
});
});
const { subject, text, html } = resetEmail(user.first, resetUrl(token));
const sent = await sendTo(email, subject, text, html);
// Only a mail that left the building counts. A send that failed gave the coordinator nothing,
// so charging them for it would shut them out for an hour over a mail outage.
if (sent) fail(mailKey, 60 * 60 * 1000);
else console.error("[forgot] reset requested but mail could not be sent for user", user.id);
}
}
// Told to the caller regardless, so the answer carries no information about the address.
return NextResponse.json({ ok: true, mail: transactionalConfigured() });
}
+103
View File
@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
import { REMEMBER_MAX_AGE, pwVersion, setSessionCookie } from "@/lib/session";
import { TRUST_COOKIE, mintTicket, readTrust } from "@/lib/twofactor";
import { sameOriginJson } from "@/lib/csrf";
import { clientIp, fail, over } from "@/lib/ratelimit";
import { signInStaff } from "@/lib/staffauth";
import { verifyTurnstile } from "@/lib/turnstile";
import { recordAuthEvent } from "@/lib/audit";
export const dynamic = "force-dynamic";
// Simple in-memory throttle per IP+email (per process).
const attempts = new Map<string, { n: number; t: number }>();
/** The trail names the person, not the address they typed — see lib/audit.ts. */
const actorFor = (u: { id: string; facilityId: string; first: string; last: string; email: string }) =>
({ facilityId: u.facilityId, userId: u.id, userName: `${u.first} ${u.last}`.trim() || u.email });
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
let body: { email?: string; password?: string; cfToken?: string; remember?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const email = String(body.email || "").trim().toLowerCase().slice(0, 160);
const password = String(body.password || "").slice(0, 200);
// Spray protection independent of the per-(ip,email) counter below. Both buckets count only the
// attempts that FAILED — a whole hospital signs in from one NAT address at shift change, and a
// ceiling on attempts would have to lock that ward out to be worth anything against an attacker.
const ipKey = clientIp(req.headers);
if (over("login-ip:" + ipKey, 40, 15 * 60 * 1000) || (email && over("login-email:" + email, 25, 15 * 60 * 1000))) return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 });
if (!email || !password) return NextResponse.json({ error: "Enter your email and password." }, { status: 400 });
// nginx appends the real client IP last; earlier entries are client-supplied and spoofable.
const xff = req.headers.get("x-forwarded-for")?.split(",").map((x) => x.trim()).filter(Boolean) || [];
const ip = xff[xff.length - 1] || "local";
if (attempts.size > 5000) for (const [kk, v] of attempts) if (Date.now() - v.t > 15 * 60 * 1000) attempts.delete(kk);
const k = `${ip}|${email}`;
const a = attempts.get(k);
if (a && a.n >= 8 && Date.now() - a.t < 15 * 60 * 1000) return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 });
const cfErr = await verifyTurnstile(body.cfToken, ipKey); if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
const u = await prisma.user.findUnique({ where: { email } });
/* One box, both kinds of account.
*
* A wearer reaches the product the way anyone else does — the home page, then Log in — and types
* the details they set up in the staff app. So when this address has no coordinator account, the
* register is asked before the answer is called wrong.
*
* A coordinator account always wins: it is the one with the counter, the orders and the register
* behind it, and a coordinator who also wears a uniform can open their own record from inside the
* app. One address therefore has one destination, every time.
*
* This is a lookup, not a second attempt. "Try the coordinator, and if that fails try the staff
* one" would score a failure against every single staff sign-in, and these ceilings count
* failures — behind one hospital's NAT address at shift change that is a locked-out ward.
*/
if (!u) {
const s = await signInStaff(email, password, ipKey, false);
if (s.kind === "ok") return NextResponse.json({ ok: true, name: s.name, staff: true });
if (s.kind === "error") return NextResponse.json({ error: s.error }, { status: s.status });
// `none`: no staff account either, so this falls through to the answer below, which counts the
// failure once and says the same thing it has always said.
}
const ok = u ? await bcrypt.compare(password, u.passwordHash) : await bcrypt.compare(password, "$2b$12$C6UzMDM.H6dfI/f/IKcEeO5x3FvDS3kqB6r0Jt3g7Lz0vX4o0JZ1u");
if (!u || !ok) {
attempts.set(k, { n: (a && Date.now() - a.t < 15 * 60 * 1000 ? a.n : 0) + 1, t: Date.now() });
fail("login-ip:" + ipKey, 15 * 60 * 1000);
if (email) fail("login-email:" + email, 15 * 60 * 1000);
// An address with no account here is recorded nowhere: there is no facility to file it under,
// and a log of attempts on addresses that don't exist would be a list of other people's email
// addresses that nobody asked us to keep.
if (u) recordAuthEvent(actorFor(u), "auth:signin.failed", ipKey);
return NextResponse.json({ error: "Email or password doesnt match." }, { status: 401 });
}
attempts.delete(k);
if (u.inactive) {
// The right password on an account that has been taken away is worth knowing about.
recordAuthEvent(actorFor(u), "auth:signin.refused", ipKey, "inactive");
return NextResponse.json({ error: "This account has been deactivated. Ask an admin at your facility to reactivate it." }, { status: 403 });
}
const fac = await prisma.facility.findUnique({ where: { id: u.facilityId }, select: { isDemo: true, ssoEnabled: true, ssoRequired: true } });
if (fac?.isDemo) return NextResponse.json({ error: "Demo accounts cant log in here — open the demo from the home page." }, { status: 403 });
// The facility has decided its people sign in through its own identity provider. The password
// was right, and it is still refused — except for the admin the facility keeps as its fire
// escape. The box sends them on to single sign-on rather than reporting a failure.
if (fac?.ssoEnabled && fac.ssoRequired && !u.ssoBreakGlass) {
recordAuthEvent(actorFor(u), "auth:signin.refused", ipKey, "sso required");
return NextResponse.json({ error: "Your facility signs in with single sign-on.", ssoRequired: true }, { status: 403 });
}
// With a second factor on the account the password alone opens nothing. The ticket says only
// "this password was correct", is accepted by no other endpoint, and expires in five minutes.
// A browser that entered a code within the last thirty days and asked to be trusted skips it;
// the trust token is bound to the password version, so a changed password asks again.
const trusted = !!u.totpEnabledAt && readTrust(req.cookies.get(TRUST_COOKIE)?.value, u.id, pwVersion(u.passwordHash));
if (u.totpEnabledAt && !trusted) {
return NextResponse.json({ need2fa: true, ticket: mintTicket(u.id, pwVersion(u.passwordHash)) });
}
await setSessionCookie(u.id, u.passwordHash, false, body.remember === true ? REMEMBER_MAX_AGE : undefined);
recordAuthEvent(actorFor(u), "auth:signin", ipKey, trusted ? "password+trusted" : "password");
return NextResponse.json({ ok: true, name: `${u.first} ${u.last}`, role: u.role });
}
+22
View File
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { clearSessionCookie, currentUser } from "@/lib/session";
import { sameOriginJson } from "@/lib/csrf";
import { clientIp } from "@/lib/ratelimit";
import { recordAuthEvent } from "@/lib/audit";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req, false); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
// Read the session before dropping it, so the trail can say who left. An unauthenticated call
// still clears the cookie and still answers ok — signing out must never fail.
const user = await currentUser();
await clearSessionCookie();
if (user) {
recordAuthEvent(
{ facilityId: user.facilityId, userId: user.id, userName: `${user.first} ${user.last}`.trim() || user.email },
"auth:signout", clientIp(req.headers),
);
}
return NextResponse.json({ ok: true });
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
export const dynamic = "force-dynamic";
/* Which door does this address belong at?
*
* The sign-in screen asks for the address first and only then shows a password box, a single
* sign-on button or a pointer to the staff app. This answers the last of those: an address that
* has no coordinator account but does have a staff-app account belongs in the staff app, and
* telling the person so beats a "wrong password" they can never get past. It answers nothing about
* coordinator accounts — a coordinator address and an unknown address get the same reply, so the
* box cannot be used to test which addresses have one. Throttled per connection like the SSO lookup. */
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
if (!allow("lookup:" + clientIp(req.headers), 60, 15 * 60 * 1000)) return NextResponse.json({ staff: false });
let body: { email?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160);
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ staff: false });
const user = await prisma.user.findUnique({ where: { email }, select: { id: true } });
if (user) return NextResponse.json({ staff: false });
const acc = await prisma.staffAccount.findUnique({ where: { email }, select: { id: true } });
return NextResponse.json({ staff: !!acc });
}
+96
View File
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
import { allow, clientIp } from "@/lib/ratelimit";
import { sameOriginJson } from "@/lib/csrf";
import { pwVersion, setSessionCookie } from "@/lib/session";
import { mintTicket } from "@/lib/twofactor";
import { hashResetToken } from "@/lib/reset";
import { recordAuthEvent } from "@/lib/audit";
export const dynamic = "force-dynamic";
const MIN_PASSWORD = 8;
/* Complete a password reset.
*
* Changing the hash invalidates every existing session for that user on its own — the session
* cookie carries a version derived from the password hash — so a reset also kicks out whoever
* prompted it, which is the behaviour you want if the reason was a shared or stolen password. */
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const ip = clientIp(req.headers);
if (!allow("reset-ip:" + ip, 100, 60 * 60 * 1000)) {
return NextResponse.json({ error: "Too many attempts — try again later." }, { status: 429 });
}
let body: { token?: unknown; password?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const token = String(body.token ?? "").trim().slice(0, 400);
const password = String(body.password ?? "");
if (!token) return NextResponse.json({ error: "That link is incomplete. Ask for a new one." }, { status: 400 });
if (password.length < MIN_PASSWORD) {
return NextResponse.json({ error: `Use at least ${MIN_PASSWORD} characters.` }, { status: 400 });
}
// Looked up by hash, so the raw token never has to be compared against stored material.
const row = await prisma.passwordReset.findUnique({
where: { tokenHash: hashResetToken(token) },
select: {
id: true, userId: true, expiresAt: true, usedAt: true,
user: { select: { inactive: true, passwordHash: true, totpEnabledAt: true, facilityId: true, first: true, last: true, email: true } },
},
});
const dead = !row || row.usedAt || row.expiresAt.getTime() < Date.now() || row.user.inactive;
if (dead) {
return NextResponse.json({ error: "That link has expired or has already been used. Ask for a new one." }, { status: 400 });
}
const hash = await bcrypt.hash(password, 12);
await prisma.$transaction(async (tx) => {
// Consume the token in the same write as the password change, so a double submit can't set the
// password twice or leave a live token behind.
const consumed = await tx.passwordReset.updateMany({
where: { id: row.id, usedAt: null },
data: { usedAt: new Date() },
});
if (consumed.count !== 1) throw new Error("token already consumed");
await tx.user.update({ where: { id: row.userId }, data: { passwordHash: hash } });
// Any other outstanding requests for this account die with it.
await tx.passwordReset.updateMany({ where: { userId: row.userId, usedAt: null }, data: { usedAt: new Date() } });
}).catch(() => null);
const fresh = await prisma.user.findUnique({ where: { id: row.userId }, select: { passwordHash: true } });
if (!fresh || fresh.passwordHash !== hash) {
return NextResponse.json({ error: "That link has expired or has already been used. Ask for a new one." }, { status: 400 });
}
const actor = {
facilityId: row.user.facilityId,
userId: row.userId,
userName: [row.user.first, row.user.last].filter(Boolean).join(" ").trim() || row.user.email,
};
// A second factor is a second factor here too. Control of the mailbox is one proof, and on an
// account with TOTP the front door refuses to open on one proof — so this door must not either,
// or resetting the password would be the supported way around the authenticator, and the new
// password would then be enough to turn it off for good.
//
// The same five-minute ticket the sign-in screen uses, accepted by the same endpoint: nothing new
// to keep, nothing new to get wrong.
if (row.user.totpEnabledAt) {
recordAuthEvent(actor, "auth:password.reset", ip, "email-link");
return NextResponse.json({ need2fa: true, ticket: mintTicket(row.userId, pwVersion(hash)) });
}
// Otherwise sign them straight in: they have just proven control of the mailbox and chosen a
// password, and making them type it again immediately is friction with no security value.
await setSessionCookie(row.userId, hash);
recordAuthEvent(actor, "auth:password.reset", ip, "email-link");
recordAuthEvent(actor, "auth:signin", ip, "reset");
return NextResponse.json({ ok: true });
}
+100
View File
@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
import { setSessionCookie } from "@/lib/session";
import { allow, clientIp } from "@/lib/ratelimit";
import { sameOriginJson } from "@/lib/csrf";
import { verifyTurnstile } from "@/lib/turnstile";
import { sendTo, transactionalConfigured } from "@/lib/mail";
import { switches } from "@/lib/switches";
import { alertNewSignup } from "@/lib/ops/alerts";
import { recordAuthEvent } from "@/lib/audit";
import { TRIAL_DAYS } from "@/lib/plan";
import { sendBillingMail, templates } from "@/lib/billing-mail";
import { welcomeEmail } from "@/lib/accountmail";
export const dynamic = "force-dynamic";
/* Starting staff groups by healthcare setting. Generic titles only — every room renames them. */
const GROUP_SEEDS: Record<string, { staffGroups: string[]; nursingGroups: string[]; kitGroups: string[] }> = {
hospital: { staffGroups: ["Registered Nurse", "Enrolled Nurse", "Allied Health", "Support Services", "Security"], nursingGroups: ["Registered Nurse", "Enrolled Nurse"], kitGroups: ["Support Services"] },
aged_care: { staffGroups: ["Registered Nurse", "Enrolled Nurse", "Personal Care Worker", "Hospitality", "Maintenance"], nursingGroups: ["Registered Nurse", "Enrolled Nurse"], kitGroups: ["Hospitality", "Maintenance"] },
community: { staffGroups: ["Registered Nurse", "Enrolled Nurse", "Allied Health", "Administration"], nursingGroups: ["Registered Nurse", "Enrolled Nurse"], kitGroups: ["Administration"] },
};
const STATE_ZONES: Record<string, string> = {
QLD: "Australia/Brisbane", NSW: "Australia/Sydney", ACT: "Australia/Sydney", VIC: "Australia/Melbourne", TAS: "Australia/Hobart",
SA: "Australia/Adelaide", NT: "Australia/Darwin", WA: "Australia/Perth", NZ: "Pacific/Auckland",
};
/* Creating a facility.
*
* The address typed here is not verified, and deliberately isn't: a confirmation step in front of
* a linen room's first ten minutes is a wall, and a facility half-created behind an unclicked link
* is worse than one created. But it is the *only* way back in — /api/auth/forgot answers a
* stranger and the owner identically, so a typo produces no signal at all until the day the
* password is forgotten, and by then the facility is unreachable and undeletable.
*
* So the address is exercised immediately instead. A note goes to it saying, in as many words,
* that this is the address that recovers the account, and the answer here says whether it was
* sent — which is what lets the sign-up screen show the address back and tell someone who never
* receives it what to do about it while they are still signed in and can still act.
*/
export async function POST(req: NextRequest) {
const sw = await switches();
if (!sw.signupsOpen) return NextResponse.json({ error: "New facility sign-ups are closed." }, { status: 403 });
const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
if (!allow("signup:" + clientIp(req.headers), 5, 60 * 60 * 1000)) return NextResponse.json({ error: "Too many sign-ups from this connection — try again later." }, { status: 429 });
let b: Record<string, string>;
try { b = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const first = String(b.first || "").trim().slice(0, 80), last = String(b.last || "").trim().slice(0, 80);
const facility = String(b.facility || "").trim().slice(0, 120);
const email = String(b.email || "").trim().toLowerCase().slice(0, 160);
const password = String(b.password || "");
if (!first || !last || !facility) return NextResponse.json({ error: "Name and facility are required." }, { status: 400 });
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ error: "Enter a valid work email." }, { status: 400 });
if (password.length < 8) return NextResponse.json({ error: "Password must be at least 8 characters." }, { status: 400 });
const cfErr = await verifyTurnstile(b.cfToken, clientIp(req.headers)); if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
if (await prisma.user.findUnique({ where: { email } })) return NextResponse.json({ error: "That email already has an account — log in instead." }, { status: 409 });
const u = await prisma.$transaction(async (tx) => {
// No staff groups: the facility names its own. Any list handed over here would be one employer's
// organisation chart on another employer's register, and a group sitting on a route nobody
// chose decides who is handed a starting kit. Both route lists start empty with it, so until the
// coordinator puts a group on the FTE table or the starting kit, everybody is on manager approval
// and nobody has been promised a kit the counter would not hand over.
// Until plans are live the page still says free, so a facility created today is grandfathered:
// free with everything, for good. Once they are live a new room starts on the plan it chose —
// Hosted Small, free, or a Hosted Facility trial with its end date set now. Anything else
// sent as `plan` is Hosted Small: the free room is the safe misreading.
const trial = sw.plansLive && b.plan === "hosted_facility";
const planData = !sw.plansLive
? { grandfathered: true, planStatus: "free" }
: trial
? { plan: "hosted_facility", planStatus: "trial", trialEndsAt: new Date(Date.now() + TRIAL_DAYS * 86_400_000) }
: { plan: "hosted_small", planStatus: "free" };
// Two optional answers from the sign-up screen. The setting seeds the staff groups the room
// starts with (renamed or removed freely under Settings); the state sets the time zone counts
// and month-end are read in. Neither is required, and "other"/blank leaves the old defaults.
const seed = GROUP_SEEDS[String(b.setting || "")] || {};
const timezone = STATE_ZONES[String(b.state || "").toUpperCase()];
const f = await tx.facility.create({ data: { name: facility, coordinator: `${first} ${last}`, ...planData, ...seed, ...(timezone ? { timezone } : {}) } });
return tx.user.create({ data: { facilityId: f.id, email, passwordHash: await bcrypt.hash(password, 12), first, last, title: "Uniform Coordinator", role: "ADMIN" } });
});
await setSessionCookie(u.id, u.passwordHash);
recordAuthEvent({ facilityId: u.facilityId, userId: u.id, userName: `${first} ${last}`.trim() || email }, "auth:signup", clientIp(req.headers));
alertNewSignup({ id: u.facilityId, name: facility }); // the facility's name only — never the person
const em = welcomeEmail(first, facility);
const mailed = await sendTo(email, em.subject, em.text, em.html);
// A room on a trial also gets the trial letter: what the 30 days include, when they end, and
// that no card was taken. Not awaited — the welcome above is the one sign-up waits for.
void (async () => {
const f = await prisma.facility.findUnique({ where: { id: u.facilityId }, select: { planStatus: true, trialEndsAt: true } });
if (f?.planStatus === "trial" && f.trialEndsAt) await sendBillingMail(u.facilityId, (ctx) => templates.trialStarted(ctx, { first, endsAt: f.trialEndsAt }));
})();
if (!mailed && transactionalConfigured()) console.error("[signup] welcome mail could not be sent for user", u.id);
// `mailed` is false when no SMTP is configured at all, which is a different thing from a bad
// address — the screen says so rather than pretending the address has been proven.
return NextResponse.json({ ok: true, email, mailed, mail: transactionalConfigured() });
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { currentUser } from "@/lib/session";
import { prisma } from "@/lib/db";
import { exportBackup } from "@/lib/ops";
import { facilityToday } from "@/lib/compute";
export const dynamic = "force-dynamic";
export async function GET() {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
if (user.role !== "ADMIN") return NextResponse.json({ error: "Admin only" }, { status: 403 });
const data = await exportBackup(user);
// The date on the filename is the day where the linen room stands, not where the box is. It has
// to agree with the lastBackup stamp exportBackup writes against the same facility, or a room
// taking a backup at eight in the morning ends up with a file named for yesterday sitting beside
// a settings screen that says it was taken today.
const fac = await prisma.facility.findUniqueOrThrow({ where: { id: user.facilityId }, select: { timezone: true } });
return new NextResponse(JSON.stringify(data, null, 1), {
headers: {
"content-type": "application/json; charset=utf-8",
"content-disposition": `attachment; filename="threadcount-backup-${facilityToday(fac.timezone)}.json"`,
},
});
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
/* Is this server actually able to do its job?
*
* The deploy probes /app, which proves the process is serving HTML — but /app renders a redirect to
* the sign-in page whether or not Prisma can reach the database, so the one failure that takes the
* whole product down is exactly the one that probe cannot see. This asks the database a question
* instead, and answers 503 when it cannot.
*
* No auth and no cache on purpose: it is watched continuously by an uptime monitor with no account,
* and it must never answer from a cached success. It is listed in proxy.ts's `publicApi` for the
* same reason. Nothing about the facility, the schema or the error is returned — a monitor needs a
* status code, and an unauthenticated caller is owed nothing more.
*/
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({ ok: true }, { headers: { "cache-control": "no-store" } });
} catch (e) {
console.error("[health] database unreachable:", (e as Error).message);
return NextResponse.json({ ok: false }, { status: 503, headers: { "cache-control": "no-store" } });
}
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { currentUser } from "@/lib/session";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
/** Serves the signed-in user's facility logo (stored as a data URL). */
export async function GET() {
const user = await currentUser();
if (!user) return new NextResponse(null, { status: 401 });
const fac = await prisma.facility.findUnique({ where: { id: user.facilityId }, select: { logoData: true } });
const m = /^data:(image\/(?:png|jpeg|jpg|gif|webp));base64,([A-Za-z0-9+/=]+)$/.exec(fac?.logoData || "");
if (!m) return new NextResponse(null, { status: 404 });
return new NextResponse(Buffer.from(m[2], "base64"), { headers: { "content-type": m[1], "cache-control": "private, no-cache", "x-content-type-options": "nosniff", "content-security-policy": "sandbox" } });
}
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/session";
import { allow } from "@/lib/ratelimit";
import { gtinInfo } from "@/lib/compute";
export const dynamic = "force-dynamic";
export type LookupResult = {
code: string;
gtin: ReturnType<typeof gtinInfo>;
enabled: boolean; // is public lookup turned on for this facility
found: boolean;
name?: string;
brand?: string;
category?: string;
source?: string;
note?: string; // why there's no result, in plain words
};
const TIMEOUT_MS = 4500;
const cache = new Map<string, { at: number; v: Omit<LookupResult, "enabled" | "gtin" | "code"> }>();
const CACHE_MS = 12 * 60 * 60 * 1000;
async function getJson(url: string): Promise<unknown | null> {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), TIMEOUT_MS);
try {
const r = await fetch(url, { signal: ac.signal, headers: { accept: "application/json", "user-agent": "ThreadCount/1.0 (uniform stock management)" }, cache: "no-store" });
if (!r.ok) return null;
return await r.json();
} catch { return null; } finally { clearTimeout(t); }
}
/** UPCitemdb's keyless trial tier — small daily quota per server IP, so misses are expected. */
async function upcItemDb(gtin: string) {
const j = await getJson(`https://api.upcitemdb.com/prod/trial/lookup?upc=${encodeURIComponent(gtin)}`) as { items?: { title?: string; brand?: string; category?: string }[] } | null;
const it = j?.items?.[0];
if (!it?.title) return null;
return { name: String(it.title).slice(0, 160), brand: String(it.brand || "").slice(0, 80), category: String(it.category || "").slice(0, 80), source: "UPCitemdb" };
}
/** Open Products Facts — the non-food sibling of Open Food Facts; open data, no key. */
async function openProductsFacts(gtin: string) {
const j = await getJson(`https://world.openproductsfacts.org/api/v2/product/${encodeURIComponent(gtin)}.json?fields=product_name,brands,categories`) as { status?: number; product?: { product_name?: string; brands?: string; categories?: string } } | null;
const pr = j?.product;
if (j?.status !== 1 || !pr?.product_name) return null;
return { name: String(pr.product_name).slice(0, 160), brand: String(pr.brands || "").slice(0, 80), category: String(pr.categories || "").slice(0, 80), source: "Open Products Facts" };
}
export async function GET(req: NextRequest) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
if (user.role !== "ADMIN") return NextResponse.json({ error: "Admin only" }, { status: 403 });
const gtin = gtinInfo(req.nextUrl.searchParams.get("code") || "");
const base = { code: gtin.code, gtin, found: false } as LookupResult;
if (!gtin.code) return NextResponse.json({ ...base, enabled: false, note: "No barcode given." });
const fac = await prisma.facility.findUnique({ where: { id: user.facilityId }, select: { barcodeLookup: true } });
const enabled = !!fac?.barcodeLookup;
if (!enabled) return NextResponse.json({ ...base, enabled: false, note: "Product lookup is off. Turn it on in Settings → Data if you want ThreadCount to ask a public barcode database for a name." });
// Only real retail GTINs are worth sending anywhere; a mis-read or an in-house code never matches.
if (!gtin.valid || !["EAN-13", "UPC-A", "EAN-8", "GTIN-14"].includes(gtin.kind)) {
return NextResponse.json({ ...base, enabled, note: gtin.kind ? "The check digit doesn't match, so this wasn't looked up — scan it again." : "Not a standard retail barcode, so there's nothing to look up. Type the details in." });
}
if (!allow("lookup:" + user.facilityId, 120, 60 * 60 * 1000)) return NextResponse.json({ ...base, enabled, note: "Too many lookups this hour — type the details in for now." }, { status: 429 });
const hit = cache.get(gtin.digits);
if (hit && Date.now() - hit.at < CACHE_MS) return NextResponse.json({ ...base, enabled, ...hit.v });
let found = await upcItemDb(gtin.digits);
if (!found) found = await openProductsFacts(gtin.digits);
const v = found
? { found: true, ...found }
: { found: false, note: "No public listing for this barcode — normal for workwear and hospital uniforms. Type the details in once and the barcode stays bound." };
cache.set(gtin.digits, { at: Date.now(), v });
if (cache.size > 500) for (const k of [...cache.keys()].slice(0, 100)) cache.delete(k);
return NextResponse.json({ ...base, enabled, ...v });
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { currentUser } from "@/lib/session";
import { OpError, bumpRev, demoGuard, restoreBackup, runOp } from "@/lib/ops";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { recordAudit } from "@/lib/audit";
import { report } from "@/lib/glitchtip";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
if (parseInt(req.headers.get("content-length") || "0", 10) > 60 * 1024 * 1024) return NextResponse.json({ error: "Request too large" }, { status: 413 });
let body: { op?: string; payload?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad JSON" }, { status: 400 }); }
const op = String(body.op || "");
if (!allow("mutate:" + user.id, 600, 60 * 1000)) return NextResponse.json({ error: "Slow down — too many changes in a minute." }, { status: 429 });
if (op === "photo.put" && !allow("photo:" + user.facilityId, 120, 60 * 60 * 1000)) return NextResponse.json({ error: "Photo limit reached for this hour." }, { status: 429 });
if ((op === "backup.restore" || op === "import.rows") && !allow("bulk:" + user.id, 20, 10 * 60 * 1000)) return NextResponse.json({ error: "Too many imports — wait a few minutes." }, { status: 429 });
try {
if (op === "backup.restore") demoGuard(user, op);
const result = op === "backup.restore" ? await restoreBackup(user, body.payload) : await runOp(user, op, body.payload);
// Only after it actually succeeded, and only from here: every one of the 57 ops passes through
// this one function, so the trail can't be forgotten in a new case branch later.
recordAudit(user, op, body.payload, clientIp(req.headers));
// Handed back so the screen that made this change does not bounce again when it next polls.
const rev = await bumpRev(user.facilityId);
return NextResponse.json({ ok: true, result, rev });
} catch (e) {
if (e instanceof OpError) return NextResponse.json({ error: e.message }, { status: e.status });
// Reported from here, not from instrumentation.ts: onRequestError only sees what Next itself
// catches, and an exception caught in this handler never reaches it. Every write in the product
// comes through this line, so without it the whole write path fails invisibly.
report({ error: e, where: "server", url: "/api/mutate", tags: { op } });
console.error(`[mutate ${op}]`, e);
return NextResponse.json({ error: "Something went wrong — nothing was saved." }, { status: 500 });
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/session";
import { parseDataUrl, readPhoto } from "@/lib/photostore";
export const dynamic = "force-dynamic";
/* Serves a stored capture or signature to a signed-in user of the same facility.
*
* Images live on disk now; rows written before that move still carry a base64 data URL, so both
* are handled and old records keep working without a flag day. */
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const { id } = await ctx.params;
// Scoped by facility in the query: a photo id from another room is simply not found.
const ph = await prisma.photo.findFirst({
where: { id, facilityId: user.facilityId },
select: { data: true, path: true, mime: true },
});
if (!ph) return NextResponse.json({ error: "Not found" }, { status: 404 });
let mime = ph.mime;
let bytes: Buffer | null = null;
if (ph.path) {
bytes = await readPhoto(ph.path);
} else if (ph.data) {
const parsed = parseDataUrl(ph.data);
if (parsed) { mime = parsed.mime; bytes = parsed.bytes; }
}
if (!bytes) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!/^image\/(jpeg|png)$/.test(mime)) return NextResponse.json({ error: "Bad photo" }, { status: 500 });
return new NextResponse(new Uint8Array(bytes), {
headers: {
"content-type": mime,
"cache-control": "private, max-age=3600",
"content-disposition": "inline",
"x-content-type-options": "nosniff",
"content-security-policy": "sandbox",
},
});
}
+220
View File
@@ -0,0 +1,220 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/session";
import { bagLines, linesSummary, reqLines } from "@/lib/staffdata";
import { decisionSummary, garmentCount } from "@/lib/staffreq";
export const dynamic = "force-dynamic";
/* The linen room's view of staff requests.
*
* Its own endpoint rather than part of the snapshot, for the same reason the audit trail is: this
* grows without limit, and putting it in the snapshot would make every page in the app heavier
* forever to serve one screen.
*/
export async function GET(req: NextRequest) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
/* One person's requests, or the whole facility's.
*
* `?staff=` is how a staff record asks for its own order-form history. Without it that screen
* pulled the facility's last 400 requests — every line, message and event on each — and kept the
* handful belonging to one person, which is a large answer to a small question on a busy
* register. Worse, that person's older requests fell off the end of the 400 and simply were not
* on their record any more. Scoped, the ceiling is per person, and either way it is reported
* back so a screen can say it has been reached rather than ending a history without a word.
*/
const staffId = (req.nextUrl.searchParams.get("staff") || "").trim().slice(0, 64);
const requestLimit = staffId ? 200 : 400;
// One more row than is returned, so "there are older ones than these" is something we know
// rather than something guessed from a full page.
const found = await prisma.request.findMany({
where: { facilityId: user.facilityId, ...(staffId ? { subjectId: staffId } : {}) },
orderBy: { createdAt: "desc" },
take: requestLimit + 1,
include: {
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
subject: { select: { first: true, last: true, num: true, dept: true } },
messages: { orderBy: { createdAt: "asc" }, select: { id: true, fromStaff: true, authorName: true, body: true, createdAt: true } },
events: { orderBy: { at: "asc" }, select: { id: true, label: true, meta: true, actorName: true, at: true } },
},
});
const requests = found.slice(0, requestLimit);
const moreRequests = found.length > requestLimit;
const mapped = requests.map((r) => {
/* Every line, and separately the ones that are actually a pick.
*
* The linen room needs both. `lines` is the record — a declined fleece still belongs on the
* order the wearer will read — while `bag` is the work: what to take off the shelf, put in
* the bag and hand across the counter. Picking from `lines` would put a garment the manager
* refused into somebody's hands, so the two are never the same field. */
const lines = reqLines(r.lines);
const bag = bagLines(lines);
return {
id: r.id, code: r.code, status: r.status,
staffId: r.subjectId,
staffName: `${r.subject.first} ${r.subject.last}`.trim(),
staffNum: r.subject.num, ward: r.subject.dept,
lines, bag,
summary: linesSummary(lines), garments: garmentCount(bag), lineCount: lines.length,
decision: decisionSummary(lines),
reason: r.reason, note: r.note,
managerName: r.managerName,
/* Who the approver is, not just how their name is spelled. A manager may now approve a
* request raised for herself, and the only thing that can show that happened is this id
* beside the subject's — the name on its own would have any screen comparing two spellings
* of the same person, which is precisely how a self-approval goes unnoticed. */
managerId: r.managerId,
declineReason: r.declineReason,
route: r.route, collectCode: r.collectCode, holdUntil: r.holdUntil,
signerName: r.signerName, signerRole: r.signerRole,
signedAt: r.signedAt?.toISOString() ?? null,
claimedAt: r.claimedAt?.toISOString() ?? null,
/* Who raised it, and which person on the register that is.
*
* The name alone is not enough for the queue screen: it builds the list of people a stranded
* request can be handed to, and the one name certain to be refused is the person who raised
* it — a manager asking for one of her own reports' garments is exactly why the request
* escalated with nobody to approve it. Told only her name, the screen would have to match
* her by spelling against a ward where two people share one, which is how the wrong person
* drops out of a dropdown.
*
* Only the staff column, because only it can ever name somebody who could approve anything.
* A raise at the counter is stamped with the coordinator's own account instead, and a
* coordinator is not on the ward register at all; a wearer raising for herself is stamped
* with neither. Both arrive here as null, which is right — neither is a name this queue
* could offer. */
raisedById: r.raisedByStaffId,
raisedByName: r.raisedByName,
createdAt: r.createdAt.toISOString(),
decidedAt: r.decidedAt?.toISOString() ?? null,
messages: r.messages.map((m) => ({ id: m.id, fromStaff: m.fromStaff, authorName: m.authorName, body: m.body, at: m.createdAt.toISOString() })),
events: r.events.map((e) => ({ id: e.id, label: e.label, meta: e.meta, actorName: e.actorName, at: e.at.toISOString() })),
};
});
/* Everything below is the linen room's queue screen — open disputes, the kit check, the
* waitlist, damage nobody has handed back. A staff record asks for one person's order forms and
* reads none of it, so a scoped ask stops here instead of running four more facility-wide
* queries whose answers are thrown away. Those keys are absent from a scoped reply rather than
* empty: an empty list would read as "there are none", which nobody asked and nobody knows. */
if (staffId) return NextResponse.json({ requests: mapped, requestLimit, moreRequests });
const [disputes, cycle, waiting, damage] = await Promise.all([
prisma.recordDispute.findMany({
where: { facilityId: user.facilityId, resolvedAt: null },
orderBy: { createdAt: "desc" },
take: 100,
include: { staff: { select: { first: true, last: true, num: true, dept: true } } },
}),
prisma.kitCheck.findFirst({
where: { facilityId: user.facilityId, closedAt: null },
orderBy: { openedAt: "desc" },
select: { id: true, dueBy: true, openedAt: true, openedBy: true, _count: { select: { answers: true } } },
}),
prisma.waitlistEntry.findMany({
where: { facilityId: user.facilityId, leftAt: null, acceptedAt: null },
orderBy: { createdAt: "asc" },
include: {
staff: { select: { first: true, last: true, num: true, dept: true } },
item: { select: { item: true, sizes: true } },
},
}),
// Damage reports the counter has not yet taken the garment back for. Reporting damage and
// asking for a replacement are two separate acts in the staff app, so a report can arrive with
// no request behind it — and until this list existed nothing in the product ever showed one to
// anybody, which made the Damage screen's promise ("it comes off your record when you hand it
// in at the counter") a promise no screen could keep.
prisma.damageReport.findMany({
where: { facilityId: user.facilityId, handedInAt: null },
orderBy: { createdAt: "desc" },
take: 100,
include: {
staff: { select: { first: true, last: true, num: true, dept: true } },
issue: { select: { sizeIndex: true, item: { select: { item: true, sizes: true } } } },
},
}),
]);
/* What the open kit check has actually turned up.
*
* The cycle used to be reported to the linen room as a bare count of answers, which is the one
* thing about it that doesn't matter: nobody opens a kit check to find out how many people
* replied. The answers are the point — every one where somebody could not account for what the
* record says they hold — and until this query existed no screen, export or report in the
* product read them, so the whole cycle collected evidence into a table nothing looked at.
*
* Only the shortfalls, and only for the cycle still open. An answer that matches the record is
* the record agreeing with itself; a closed cycle is history and belongs with the rest of it.
*/
const answers = cycle
? await prisma.kitCheckAnswer.findMany({
where: { kitCheckId: cycle.id, confirmed: { lt: prisma.kitCheckAnswer.fields.onRecord } },
orderBy: { answeredAt: "desc" },
take: 400,
include: {
staff: { select: { id: true, first: true, last: true, num: true, dept: true } },
item: { select: { item: true, sizes: true } },
},
})
: [];
// DamageReport.requestId is a plain column rather than a relation, so the replacement's code is
// looked up here. It is what the linen room actually needs: "torn, and she has asked for R-0042"
// is a different job from "torn, and she has not".
const replacementCodes = new Map<string, string>();
const replacementIds = damage.map((d) => d.requestId).filter((x): x is string => !!x);
if (replacementIds.length) {
const reps = await prisma.request.findMany({
where: { facilityId: user.facilityId, id: { in: replacementIds } },
select: { id: true, code: true },
});
for (const r of reps) replacementCodes.set(r.id, r.code);
}
return NextResponse.json({
requests: mapped, requestLimit, moreRequests,
disputes: disputes.map((d) => ({
id: d.id, body: d.body,
staffName: `${d.staff.first} ${d.staff.last}`.trim(),
staffNum: d.staff.num, ward: d.staff.dept,
at: d.createdAt.toISOString(),
})),
cycle: cycle && {
id: cycle.id, dueBy: cycle.dueBy, openedBy: cycle.openedBy,
openedAt: cycle.openedAt.toISOString(), answers: cycle._count.answers,
},
shortfalls: answers.map((a) => ({
id: a.id,
staffId: a.staff.id,
staffName: `${a.staff.first} ${a.staff.last}`.trim(),
staffNum: a.staff.num, ward: a.staff.dept,
item: a.item.item, size: String(a.item.sizes[a.sizeIndex] ?? a.sizeIndex),
onRecord: a.onRecord, confirmed: a.confirmed, short: a.onRecord - a.confirmed,
at: a.answeredAt.toISOString(),
})),
waiting: waiting.map((w) => ({
id: w.id,
staffName: `${w.staff.first} ${w.staff.last}`.trim(),
staffNum: w.staff.num, ward: w.staff.dept,
item: w.item.item, size: String(w.item.sizes[w.sizeIndex] ?? w.sizeIndex),
since: w.createdAt.toISOString(),
offeredAt: w.offeredAt?.toISOString() ?? null,
})),
damage: damage.map((d) => ({
id: d.id, kind: d.kind, note: d.note, photoId: d.photoId,
staffId: d.staffId,
staffName: `${d.staff.first} ${d.staff.last}`.trim(),
staffNum: d.staff.num, ward: d.staff.dept,
// The garment comes off the Issue the report was raised against. That issue can be deleted
// (a wipe, a correction) and the column is SetNull, so an older report may name no garment.
item: d.issue ? d.issue.item.item : "",
size: d.issue ? String(d.issue.item.sizes[d.issue.sizeIndex] ?? d.issue.sizeIndex) : "",
requestCode: d.requestId ? replacementCodes.get(d.requestId) ?? "" : "",
at: d.createdAt.toISOString(),
})),
});
}
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/session";
import { currentStaff } from "@/lib/staffsession";
export const dynamic = "force-dynamic";
/* "Has anything changed?", answered in one integer.
*
* Every screen in the product already knows how to reload itself — a mutation ends in
* router.refresh(). What it could not know was that somebody ELSE had changed something, so a
* phone left open on a ward showed whatever the catalogue looked like when it was opened, and a
* coordinator adding a garment at the desk had to tell the counter to pull down to refresh.
*
* The obvious fix — poll the snapshot and diff it — is the expensive one: that is the facility's
* catalogue, staff register, stock and history, re-read on a timer by every open device to learn,
* almost always, that nothing happened. This returns the counter that the three mutating routes
* bump, so the cost of asking is a primary-key lookup, and the cost of the real reload is paid only
* when the number has actually moved.
*
* Both session kinds answer here. A coordinator at the desk and a wearer on a ward are watching the
* same facility, and there is nothing in a bare revision number to keep apart — it says that
* something changed, never what. Anyone with no session at all gets 401 rather than a number,
* because even "this facility is busy" is not ours to hand out.
*/
export async function GET(_req: NextRequest) {
const user = await currentUser();
const facilityId = user?.facilityId || (await currentStaff())?.facilityId;
if (!facilityId) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const f = await prisma.facility.findUnique({ where: { id: facilityId }, select: { rev: true } });
if (!f) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
// Never from a cache: a stale revision is indistinguishable from nothing having happened, which
// is the one wrong answer this endpoint can give.
return NextResponse.json({ rev: f.rev }, { headers: { "cache-control": "no-store" } });
}
+132
View File
@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { normaliseCode, setStaffCookie } from "@/lib/staffsession";
import { verifyTurnstile } from "@/lib/turnstile";
import { recordAuthEvent } from "@/lib/audit";
import { bumpRev } from "@/lib/ops";
import { SLIP_DAYS, facilityToday, slipLive } from "@/lib/compute";
export const dynamic = "force-dynamic";
const MIN_PW = 8;
/* Claiming your own record with the code the linen room printed for you.
*
* The code alone identifies the person, because it is presented before we know anything about them
* — there is no facility to scope it to and no email to look up yet. That is why it is globally
* unique, why it is 58 bits wide, and why this route is throttled to the point where working
* through the space is not a strategy.
*
* It is spent in the same update that finds it, so two people racing the same slip can't both
* claim the record; the loser gets the ordinary "code isn't right" message.
*
* It also goes stale on its own after fourteen days, because the far more likely way a slip is
* misused is not a guessed code but a printed one nobody ever collected.
*/
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const ip = clientIp(req.headers);
if (!allow("staff-activate:" + ip, 200, 15 * 60 * 1000)) {
return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 });
}
let body: { code?: unknown; email?: unknown; password?: unknown; cfToken?: unknown; agreed?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const code = normaliseCode(String(body.code ?? ""));
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160);
const password = String(body.password ?? "").slice(0, 200);
if (!code) return NextResponse.json({ error: "That code isn't right. It's twelve characters, in three groups." }, { status: 400 });
if (!/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) return NextResponse.json({ error: "Enter an email address you can get to." }, { status: 400 });
if (password.length < MIN_PW) return NextResponse.json({ error: `Use at least ${MIN_PW} characters for your password.` }, { status: 400 });
// The agreement is collected where the account is created. The screen's tick is what sets it,
// and the door checks it too so a client that skips the box gets the same answer.
if (body.agreed !== true) return NextResponse.json({ error: "Tick the box to agree to the terms of use and privacy policy." }, { status: 400 });
// Checked before the code is looked up, so a bot working through the code space is stopped by
// Cloudflare rather than by the per-IP throttle alone.
const cfErr = await verifyTurnstile(body.cfToken, ip);
if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
const staff = await prisma.staff.findUnique({
where: { activateCode: code },
select: { id: true, facilityId: true, first: true, last: true, inactive: true, activateCodeAt: true, account: { select: { id: true } }, facility: { select: { timezone: true } } },
});
// One message for every way this can fail, so the response can't be used to tell a real code from
// a spent one.
const nope = () => NextResponse.json({ error: "That code isn't right, or it has already been used. Ask the linen room for a new one." }, { status: 400 });
if (!staff || staff.inactive || staff.account) return nope();
/* An old slip is refused whether or not anyone ever claimed it. It is a bearer token on paper:
* whoever picks one out of a folder months later can bind their own email and password to this
* person's record and from then on be them — their issues, their requests, their signature on the
* ward round, and their approvals queue if they manage anyone. Fourteen days is long enough for
* someone on leave to come back to it and short enough that a forgotten one is dead by the time
* it turns up.
*
* An unstamped code counts as stale: its age is unknown, so it has to be assumed old. This leans
* on staff.selfCode in lib/ops.ts stamping activateCodeAt as it prints — if that stamp ever stops
* being written, every new slip is dead on arrival.
*
* This says plainly that the slip has expired rather than joining the deliberately vague message
* above. Landing here means the code was right, and a code is 58 bits behind a throttle and a
* Turnstile — so anyone who gets this far is holding a real slip and needs to be told that a
* reprint, not a retype, is the fix.
*
* The age is asked of slipLive() in lib/compute, the same test the staff register and the requests
* queue use to say whether a slip is still worth chasing, counted in whole days on the facility's
* own calendar. The day a coordinator's screen calls a slip expired is therefore the day this
* refuses it — never a few hours later, with a nurse who was told it was dead finding it still
* works, or one who was told it was fine being turned away. */
const tz = staff.facility.timezone;
if (!slipLive(staff.activateCodeAt, facilityToday(tz), tz)) {
return NextResponse.json(
{ error: `That code was printed ${SLIP_DAYS} or more days ago, so it has expired. Ask the linen room to print you a new slip.` },
{ status: 400 },
);
}
// The email has to be free across staff accounts. Coordinator accounts live in a different table
// and a person may legitimately be both — a linen-room supervisor who also wears the uniform.
const taken = await prisma.staffAccount.findUnique({ where: { email }, select: { id: true } });
if (taken) return NextResponse.json({ error: "That email is already on an account here. Sign in instead." }, { status: 400 });
const passwordHash = await bcrypt.hash(password, 12);
// Spend the code first, conditionally. If it has gone in the meantime, nothing was created.
// The stamp goes with the code, so a spent row can't be read as a slip still waiting out there.
const spent = await prisma.staff.updateMany({ where: { id: staff.id, activateCode: code }, data: { activateCode: null, activateCodeAt: null } });
if (spent.count !== 1) return nope();
let account;
try {
account = await prisma.staffAccount.create({
data: { facilityId: staff.facilityId, staffId: staff.id, email, passwordHash },
select: { id: true, passwordHash: true },
});
} catch {
// The code is gone but the account didn't happen — put the code back rather than stranding
// someone with a dead slip. The original print date goes back with it: a failed attempt is not
// a reprint and must not restart the fourteen days.
await prisma.staff.update({ where: { id: staff.id }, data: { activateCode: code, activateCodeAt: staff.activateCodeAt } }).catch(() => {});
return NextResponse.json({ error: "That didn't work — try again." }, { status: 500 });
}
await setStaffCookie(account.id, account.passwordHash);
recordAuthEvent(
{ facilityId: staff.facilityId, userId: staff.id, userName: `${staff.first} ${staff.last}`.trim() || email },
"staff:activate", ip,
);
// The fourth door that changes a facility's data, and the only one outside the three mutate
// routes. Without this the coordinator standing over the nurse while she activates keeps seeing
// "code outstanding" until some unrelated edit moves the revision, and reissues a code that
// can't be reissued.
await bumpRev(staff.facilityId);
return NextResponse.json({ ok: true, name: `${staff.first} ${staff.last}` });
}
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { readApprovalToken } from "@/lib/approvallink";
import { StaffOpError, decideRequest } from "@/lib/staffops";
import { recordFor } from "@/lib/audit";
import { bumpRev } from "@/lib/ops";
export const dynamic = "force-dynamic";
/* Deciding a request from the emailed link, without signing in.
*
* This is a POST and only a POST. The link in the email is a GET that renders /my/approve, and the
* decision is made from that page — because corporate mail scanners and link-preview crawlers
* fetch every URL in every message, and a GET that approved a uniform request would be approved by
* the mail gateway before the manager ever saw it.
*
* The decision itself is decideRequest()'s, not this route's. A request now carries a line per
* garment, and settling it means settling every line and then rolling the request up from them;
* an approval made here that moved only the request would leave every line `awaiting`, so the
* linen room's bag would come out empty and the wearer's order would show no decision at all.
* There is no room on this page for a garment-by-garment answer — there is no signed-in person to
* check one against — so it takes the whole-request shorthand, `approveAll`, which is the reason
* that argument exists.
*
* Single use falls out of the state machine rather than a table of spent tokens: decideRequest's
* update is conditional on the request still being `awaiting`, so the approve link and the decline
* link in the same email both stop working the moment either is used.
*/
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const ip = clientIp(req.headers);
if (!allow("staff-decide:" + ip, 200, 15 * 60 * 1000)) {
return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 });
}
let body: { token?: unknown; action?: unknown; reason?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const claim = readApprovalToken(String(body.token ?? ""));
if (!claim) return NextResponse.json({ error: "That link has expired. Open the app and use the approvals queue." }, { status: 400 });
const action = String(body.action ?? "");
if (action !== "approve" && action !== "decline") return NextResponse.json({ error: "Unknown action" }, { status: 400 });
let done: Awaited<ReturnType<typeof decideRequest>>;
try {
done = await decideRequest({
requestId: claim.rid,
managerId: claim.mid,
approveAll: action === "approve",
reason: body.reason,
});
} catch (e) {
if (!(e instanceof StaffOpError)) throw e;
/* The refusals are worded for somebody standing in their mail client, not in the app.
*
* A 403 here is decideRequest re-reading the register and finding the manager off it, or the
* wearer off it — the check that makes a fortnight-old token in a mailbox that has since been
* closed or handed on safe. Neither the sacked manager nor a stranger reading their mail is
* told which of the two it was; "ask the linen room" is where that conversation belongs.
*
* A 409 is the link already spent, and keeps the `already` flag the page reads to show the
* decision that was made rather than an error. */
if (e.status === 403) return NextResponse.json({ error: "That link is no longer valid — ask the linen room." }, { status: 403 });
if (e.status === 404) return NextResponse.json({ error: "That request is no longer there." }, { status: 404 });
if (e.status === 409) return NextResponse.json({ error: "That request has already been decided.", already: true }, { status: 409 });
return NextResponse.json({ error: e.message }, { status: e.status });
}
// Filed under the manager's own Staff id, exactly as the in-app approval is, so the log names
// the same person either way; the op says which door the decision came through, because "an
// email link, from an address we can't see" is part of the answer to who authorised this.
recordFor(
{ facilityId: done.facilityId, userId: claim.mid, userName: done.managerName },
done.status === "accepted" ? "staff:request.approve.email" : "staff:request.decline.email",
{ id: claim.rid }, ip,
);
// The third door into the facility's data, so the third place the revision has to move: a manager
// approving from their mail is exactly the change the linen room's screen is waiting to see.
await bumpRev(done.facilityId);
return NextResponse.json({ ok: true, status: done.status, notified: done.notified });
}
+45
View File
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { sameOriginJson } from "@/lib/csrf";
import { clientIp } from "@/lib/ratelimit";
import { signInStaff, staffThrottled } from "@/lib/staffauth";
import { verifyTurnstile } from "@/lib/turnstile";
export const dynamic = "force-dynamic";
/* The staff app's own door: the printed slip, the Play app's welcome, and /my/signin.
*
* The Log in box on the website reaches the same register through lib/staffauth.ts, so what counts
* as a match, what a deactivated record is told, and what lands in the audit trail are decided in
* one place for both. This route is the HTTP shape of it: the origin check, the security check, and
* the throttle asked in that order. */
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
let body: { email?: unknown; password?: unknown; cfToken?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160);
const password = String(body.password ?? "").slice(0, 200);
const ip = clientIp(req.headers);
// Asked before the security check, because a Turnstile token is good for one use and somebody who
// is already throttled should not spend theirs to be told so.
if (staffThrottled(email, ip)) {
return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 });
}
if (!email || !password) return NextResponse.json({ error: "Enter your email and password." }, { status: 400 });
// The same bot check the coordinator door has. A ward account opens one person's uniform record,
// and a manager's opens the approvals queue, so leaving this to the in-memory throttles alone
// meant a list of hospital addresses and enough patience was the whole attack.
const cfErr = await verifyTurnstile(body.cfToken, ip);
if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
// `true`: this is the register's own door, so an address with no account here is a plain wrong
// answer and is counted as one.
const r = await signInStaff(email, password, ip, true);
if (r.kind === "ok") return NextResponse.json({ ok: true, name: r.name });
if (r.kind === "error") return NextResponse.json({ error: r.error }, { status: r.status });
// Unreachable at this door: `none` is only returned when the caller asked not to be counted.
return NextResponse.json({ error: "Email or password doesnt match." }, { status: 401 });
}
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { sameOriginJson } from "@/lib/csrf";
import { clearStaffCookie, currentStaff } from "@/lib/staffsession";
import { clientIp } from "@/lib/ratelimit";
import { recordAuthEvent } from "@/lib/audit";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
// Read the session before dropping it, so the trail can say who left. Signing out still succeeds
// when there was nothing to sign out of.
const sess = await currentStaff();
await clearStaffCookie();
if (sess) {
recordAuthEvent(
{ facilityId: sess.facilityId, userId: sess.staffId, userName: `${sess.first} ${sess.last}`.trim() || sess.email },
"staff:signout", clientIp(req.headers),
);
}
return NextResponse.json({ ok: true });
}
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { currentStaff } from "@/lib/staffsession";
import { StaffOpError, runStaffOp } from "@/lib/staffops";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { recordStaffAudit } from "@/lib/audit";
import { bumpRev } from "@/lib/ops";
import { report } from "@/lib/glitchtip";
export const dynamic = "force-dynamic";
/* The one door for everything a wearer, manager or ward clerk changes.
*
* Separate from /api/mutate, and reached only with a staff session. The two never share a handler:
* a single endpoint that accepted either kind of caller would put the whole coordinator op table
* one authorisation slip away from a wearer's phone.
*/
export async function POST(req: NextRequest) {
const sess = await currentStaff();
if (!sess) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
let body: { op?: string; payload?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad JSON" }, { status: 400 }); }
const op = String(body.op || "");
if (!allow("staff-mutate:" + sess.accountId, 120, 60 * 1000)) {
return NextResponse.json({ error: "Slow down — too many changes in a minute." }, { status: 429 });
}
// Requests are the expensive ones: each sends an email to a manager. A tighter budget stops a
// stuck retry loop turning into a mailbox full of the same approval.
// damage.report and waitlist.accept raise a request (and mail the manager) through the same
// path, so they draw on the same budget — otherwise the loop just picks a different door.
if (["request.create", "damage.report", "waitlist.accept"].includes(op) && !allow("staff-request:" + sess.staffId, 12, 60 * 60 * 1000)) {
return NextResponse.json({ error: "That's a lot of requests in an hour — talk to the linen room." }, { status: 429 });
}
const ip = clientIp(req.headers);
try {
const payload = (body.payload || {}) as Record<string, unknown>;
const result = await runStaffOp(sess, op, payload);
// The same discipline as /api/mutate, and for the same reason: a uniform issued to a ward is
// authorised here as often as it is in the linen room, and "who approved this" is the question
// the trail exists to answer. Recorded only after the op actually succeeded.
recordStaffAudit(sess, op, payload, ip, result);
// Handed back so the screen that made this change does not bounce again when it next polls.
const rev = await bumpRev(sess.facilityId);
return NextResponse.json({ ok: true, result, rev });
} catch (e) {
if (e instanceof StaffOpError) return NextResponse.json({ error: e.message }, { status: e.status });
report({ error: e, where: "server", url: "/api/staff/mutate", tags: { op } });
console.error(`[staff mutate ${op}]`, e, "ip=", ip);
return NextResponse.json({ error: "Something went wrong — nothing was saved." }, { status: 500 });
}
}
+14
View File
@@ -0,0 +1,14 @@
import { redirect } from "next/navigation";
/* The activity log now lives in Settings Data & audit log. next.config.ts redirects this path as
* well; this stub keeps old links working if that map ever changes. Query strings carry over. */
export default async function Activity({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const sp = await searchParams;
const q = new URLSearchParams();
for (const [k, v] of Object.entries(sp)) {
if (k === "tab" || v === undefined) continue;
for (const one of Array.isArray(v) ? v : [v]) q.append(k, one);
}
q.set("tab", "audit");
redirect(`/app/settings?${q.toString()}`);
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { Suspense } from "react";
import Counter from "@/components/counter/Counter";
// useSearchParams (?staff=, ?mode=) needs a Suspense boundary for static rendering.
export default function CounterPage() {
return <Suspense fallback={null}><Counter /></Suspense>;
}
+28
View File
@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ManualShell from "@/components/ManualShell";
import { ManualArticle } from "@/components/ManualView";
import { findPage, neighbours } from "@/lib/manual";
/* One manual page inside the app: the same Markdown the website renders at /docs, framed by the
* app's own shell. The help mark on each screen links straight here. */
type Params = { params: Promise<{ section: string; slug: string }> };
export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { section, slug } = await params;
const p = findPage(section, slug);
return { title: p ? `${p.title} · Help` : "Help", robots: { index: false, follow: false } };
}
export default async function HelpPage({ params }: Params) {
const { section, slug } = await params;
const p = findPage(section, slug);
if (!p) notFound();
const { prev, next } = neighbours(p);
return (
<ManualShell base="/app/help" current={{ section, slug }} headings={p.headings}>
<ManualArticle page={p} base="/app/help" prev={prev} next={next} />
</ManualShell>
);
}
+26
View File
@@ -0,0 +1,26 @@
import ManualHome from "@/components/ManualHome";
import ManualShell from "@/components/ManualShell";
import FacilityRules from "@/components/FacilityRules";
export const metadata = { title: "Help", robots: { index: false, follow: false } };
/* Help inside the app: the manual's front page, with this facility's own rules above it. The rules
* panel reads the facility's settings, so a figure a coordinator has changed is the figure shown;
* the manual pages quote the defaults and say where each one is changed. */
export default function Help() {
return (
<ManualShell base="/app/help">
<ManualHome
base="/app/help"
title="Help"
lede="The ThreadCount manual, and this facility's own rules. Every screen also has a help mark beside its title that opens the page about that screen."
>
<section className="mn-section" id="rules">
<h2 className="mn-h2"><span className="n">00</span><span>This facility&rsquo;s rules</span></h2>
<p className="mn-p">Read from your settings, so these are the figures the counter applies today.</p>
<FacilityRules />
</section>
</ManualHome>
</ManualShell>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { redirect } from "next/navigation";
/* Issue Stock became the Counter. Old links, bookmarks and badge scans keep their query string. */
export default async function IssueRedirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
const sp = await searchParams;
const q = new URLSearchParams();
for (const [k, v] of Object.entries(sp)) {
if (Array.isArray(v)) v.forEach((x) => q.append(k, x));
else if (v !== undefined) q.set(k, v);
}
const qs = q.toString();
redirect(`/app/counter${qs ? `?${qs}` : ""}`);
}
+35
View File
@@ -0,0 +1,35 @@
import { redirect } from "next/navigation";
import { currentUser } from "@/lib/session";
import { buildSnapshot } from "@/lib/snapshot";
import { prisma } from "@/lib/db";
import { SnapshotProvider } from "@/lib/client";
import type { ServerCounts } from "@/lib/portalcounts";
import Shell from "@/components/Shell";
import Analytics from "@/components/Analytics";
import Helpdesk from "@/components/Helpdesk";
export const dynamic = "force-dynamic";
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const user = await currentUser();
if (!user) redirect("/auth");
const facilityId = user.facilityId;
// The four rail counts the snapshot cannot make: requests, record queries and damage live in
// their own tables and are never loaded into the snapshot. mutate()'s router.refresh() re-runs
// this layout, so the badges follow every write.
const [snap, pick, stranded, queries, damage] = await Promise.all([
buildSnapshot(user),
prisma.request.count({ where: { facilityId, status: "accepted" } }),
prisma.request.count({ where: { facilityId, status: "awaiting", managerName: "" } }),
prisma.recordDispute.count({ where: { facilityId, resolvedAt: null } }),
prisma.damageReport.count({ where: { facilityId, handedInAt: null } }),
]);
const serverCounts: ServerCounts = { pick, stranded, queries, damage };
return (
<SnapshotProvider snap={snap}>
<Shell serverCounts={serverCounts}>{children}</Shell>
<Analytics site="app" />
<Helpdesk />
</SnapshotProvider>
);
}
+355
View File
@@ -0,0 +1,355 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { PageHead, Empty, Field, ItemSizePicker, LiveRegion } from "@/components/ui";
import { ReceiveDialog } from "@/components/dialogs";
import { Figures, MoreMenu, Panel, QtyStepper, Tag } from "@/components/portal";
import { Crumb, OrdersStyles } from "@/components/orders/bits";
import { viewPhoto } from "@/lib/photo";
import { key, supplierCodeOf, ccBudgetNote, ccFor, ccOfOrder, csvOf, daysBetween, fmtDate, isOverdue, label, money, orderTotal, staffName, statusTag, supplierInfo, csvEsc } from "@/lib/compute";
import { downloadCsv, esc, openPrintWindow } from "@/lib/print";
export default function OrderDetail() {
const { id } = useParams<{ id: string }>();
const { s, isAdmin, mutate } = useSnap();
const { byId, staffById } = useDerived();
const router = useRouter();
const o = s.orders.find((x) => x.id === id);
const [rcv, setRcv] = useState(false);
const [err, setErr] = useState("");
const [pick, setPick] = useState("");
const [priceDraft, setPriceDraft] = useState<Record<string, string>>({});
const [draft, setDraft] = useState<Record<string, string>>({});
// What the coordinator has tapped on the quantity steppers but the server hasn't confirmed yet.
// The state is what the screen shows; the ref is what the next tap adds to while it is set,
// because it is current the instant a tap happens, where the state and the snapshot are both a
// render (or a whole round trip) behind.
const [qtyDraft, setQtyDraft] = useState<Record<string, number>>({});
const qtyWanted = useRef<Record<string, number>>({});
// What the snapshot said about a line as its write came back, and the lines the latest snapshot
// has. Both are read by the backstop below, from a timer: a timer armed two renders ago still
// closes over that render's copy of the order, and judging the screen out of date from a copy
// that is itself out of date is exactly how the pre-tap quantity gets back under a finger.
const qtySeen = useRef<Record<string, number>>({});
const snapLines = useRef(o?.lines);
// The field edits a debounce is still sitting on, so leaving the page can send them (see below).
const fieldWanted = useRef<Record<string, string>>({});
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
/* Anything still in a debounce when this screen goes away is sent, not thrown away.
*
* Clearing the timers on unmount was silent data loss: tap + on a quantity, or type the invoice
* number, then click straight through to another page inside the debounce window and the change
* vanished — it was on the screen as the coordinator left, and the supplier got the old figure.
* The writes go out bare because the component is already gone: there is nothing left to show an
* error in, and the record is one refresh away for whoever opens it next. */
const flush = useRef<() => void>(() => {});
useEffect(() => {
flush.current = () => {
for (const [k, v] of Object.entries(fieldWanted.current)) void mutate("order.update", { id, [k]: v });
for (const [lineId, qty] of Object.entries(qtyWanted.current)) void mutate("order.lineQty", { id, lineId, qty });
};
});
useEffect(() => { const t = timers.current, f = flush; return () => { Object.values(t).forEach(clearTimeout); f.current(); }; }, []);
useEffect(() => { snapLines.current = o?.lines; });
// Hand a line back to the snapshot once the refreshed snapshot agrees with what was tapped (or
// the line is gone). Waiting for agreement rather than for the write to return matters: the
// provider re-renders on its own the moment a write lands, still carrying the old snapshot, and
// dropping the tapped number there would flick the counter back to the old quantity and again
// look like the taps had been lost.
useEffect(() => {
setQtyDraft((d) => {
const n = Object.fromEntries(Object.entries(d).filter(([k, v]) => {
const line = o?.lines.find((l) => l.id === k);
return qtyWanted.current[k] !== undefined || (!!line && line.qty !== v);
}));
return Object.keys(n).length === Object.keys(d).length ? d : n;
});
});
const [mailMsg, setMailMsg] = useState("");
if (!o) return <section><OrdersStyles /><PageHead title="Order not found" /><Crumb href="/app/orders/all" parent="Orders" current="Not found" /><Empty><Link href="/app/orders/all">All orders</Link></Empty></section>;
const st = o.staffId ? staffById[o.staffId] : undefined;
const overdue = isOverdue(o, s.today);
const forLabel = o.orderFor === "Stock" ? "For stock" : "For " + staffName(st, "staff member");
const ccCode = ccOfOrder(s, o, staffById);
const ccNote = ccBudgetNote(s, byId, staffById, ccCode, " (incl. this one)");
function saveField(k: string, v: string) {
setDraft((d) => ({ ...d, [k]: v }));
fieldWanted.current[k] = v;
clearTimeout(timers.current[k]);
timers.current[k] = setTimeout(async () => {
// Off the pending list the moment it is on its way: a keystroke that lands after this point
// has already put its own value back and scheduled its own timer.
if (fieldWanted.current[k] === v) delete fieldWanted.current[k];
const r = await mutate("order.update", { id: o!.id, [k]: v });
if (!r.ok) setErr(r.error);
}, 400);
}
const val = (k: keyof typeof o) => (draft[k] !== undefined ? draft[k] : String(o[k] ?? ""));
/* The order as the coordinator can actually see it: the taps and the typing still sitting in a
* debounce, laid over the snapshot that has not caught up with them yet.
*
* Everything that puts this order in front of a person reads it — the lines, the total, the
* printed purchase order, the CSV, the receive dialog — so what leaves the building says what the
* screen said when it was asked for. Printing from the snapshot sent the supplier a tunic count
* one tap behind. Sending the pending write first would not have fixed it: the refreshed snapshot
* lands some time after the write returns, and the print window has to open on the click itself
* or the browser blocks it. Actions the server answers out of its own copy go through flushQty()
* instead — that is what the database has to be right about. */
const onScreen = { ...o, ref: val("ref"), invoice: val("invoice"), tracking: val("tracking"), expected: val("expected"), supplier: val("supplier"), notes: val("notes"), lines: o.lines.map((l) => (qtyDraft[l.id] !== undefined ? { ...l, qty: qtyDraft[l.id] } : l)) };
async function act(op: string, payload: unknown) { setErr(""); const r = await mutate(op, payload); if (!r.ok) setErr(r.error); return r.ok; }
/* Steppers count from what has been tapped, never from the snapshot.
*
* order.lineQty takes an absolute quantity and the snapshot only catches up once a write comes
* back, so reading l.qty on every tap meant six quick taps on the size-14 tunic all posted
* qty: 2: the line settled at 2 or 3 and the purchase order went to the supplier four tunics
* short. Each tap now adds to the pending figure and the debounce sends whatever it reached.
*
* `shown` is the number on the screen, which is the one the coordinator is counting from. It
* matters in the gap between a write landing and the refreshed snapshot arriving: the pending
* figure is cleared the moment the write returns, so a tap in that gap would otherwise fall back
* to the snapshot and count from the old quantity again — the very defect this exists to stop.
* The pending figure still wins where it exists, because two taps in one frame both read the same
* already-rendered number. */
function bumpQty(lineId: string, shown: number, by: number) {
clearTimeout(timers.current["qtyclear:" + lineId]);
const next = (qtyWanted.current[lineId] ?? shown) + by;
qtyWanted.current[lineId] = next;
setQtyDraft((d) => ({ ...d, [lineId]: next }));
clearTimeout(timers.current["qty:" + lineId]);
timers.current["qty:" + lineId] = setTimeout(() => { void sendQty(lineId); }, 300);
}
async function sendQty(lineId: string) {
const want = qtyWanted.current[lineId];
if (want === undefined) return true;
clearTimeout(timers.current["qty:" + lineId]);
const ok = await act("order.lineQty", { id: o!.id, lineId, qty: want });
// A tap that landed while this write was in the air has already raised the target; leaving it
// pending lets the timer that tap scheduled send the higher number instead of losing it here.
if (qtyWanted.current[lineId] === want) {
delete qtyWanted.current[lineId];
// Nothing was saved, so the tapped number must come off the screen now rather than sit there
// above the error looking like a quantity the supplier is going to be sent.
if (!ok) dropPendingQty(lineId);
else {
qtySeen.current[lineId] = snapLines.current?.find((l) => l.id === lineId)?.qty ?? want;
timers.current["qtyclear:" + lineId] = setTimeout(() => dropOverriddenQty(lineId, want), 2000);
}
}
return ok;
}
/* The backstop for the case where the snapshot never comes to agree — someone else editing the
* same draft line. Without it this screen would keep showing our number over theirs.
*
* It runs on a clock, so it must never act on a snapshot that is merely late. Dropping the draft
* the moment the two seconds were up put the pre-tap quantity back on the screen whenever the
* refreshed snapshot was slower than that, and the next tap counted on from it — the miscount all
* of this exists to stop. A snapshot still showing the figure it had when our write came back,
* and not the figure we wrote, has not caught up yet: the tapped number stays and this waits
* another two seconds. Once it moves — to ours, or to whatever the other coordinator saved — the
* draft has nothing left to protect and goes. */
function dropOverriddenQty(lineId: string, wrote: number) {
const line = snapLines.current?.find((l) => l.id === lineId);
if (line && line.qty !== wrote && line.qty === qtySeen.current[lineId]) { timers.current["qtyclear:" + lineId] = setTimeout(() => dropOverriddenQty(lineId, wrote), 2000); return; }
dropPendingQty(lineId);
}
// Send anything still sitting in the debounce before an action the server answers out of its own
// copy of the order — it reads the lines the database holds, not the ones on this screen — or
// before one that closes the draft to edits and would have the pending write refused.
async function flushQty() {
for (const lineId of Object.keys(qtyWanted.current)) if (!(await sendQty(lineId))) return false;
return true;
}
function dropPendingQty(lineId: string) {
clearTimeout(timers.current["qty:" + lineId]);
clearTimeout(timers.current["qtyclear:" + lineId]);
delete qtyWanted.current[lineId];
delete qtySeen.current[lineId];
setQtyDraft((d) => { const n = { ...d }; delete n[lineId]; return n; });
}
async function removeLine(lineId: string) { dropPendingQty(lineId); await act("order.lineRemove", { id: o!.id, lineId }); }
/* Every line is priced the way orderTotal() prices it — delivered units at the cost the delivery
* was invoiced at, whatever is still outstanding at today's catalogue price — so the rows a
* coordinator ticks off against the invoice add up to the total printed under them. Pricing the
* rows from the catalogue while the total came from orderTotal() left the two visibly disagreeing
* as soon as a delivery arrived at a different price, on the one screen where that sum is checked.
*
* The amounts come out of orderTotal() itself rather than a second copy of its arithmetic: what
* line n contributes is the total of the first n lines less the total of the first n1. Asking it
* about a line on its own would not do, because it draws each delivery down across the lines in
* order — two lines for the same size would then both claim the same delivery. */
const lineAmt: Record<string, number> = {};
let runTotal = 0;
for (let i = 0; i < onScreen.lines.length; i++) { const t = orderTotal({ ...onScreen, lines: onScreen.lines.slice(0, i + 1) }, byId); lineAmt[onScreen.lines[i].id] = t - runTotal; runTotal = t; }
const unitOf = (l: { id: string; itemId: string; qty: number }) => (l.qty > 0 ? lineAmt[l.id] / l.qty : byId[l.itemId]?.cost || 0);
const received = (itemId: string, size: string) => o.receipts.reduce((t, r) => t + r.lines.filter((x) => x.itemId === itemId && x.size === size).reduce((a, x) => a + x.qty, 0), 0);
/* What the delivery docket gets checked against: the units this order asked for and the units
that have actually turned up. Both are read off the same lines the total is priced from, so the
figure above the table can never disagree with the table. */
const units = onScreen.lines.reduce((t, l) => t + l.qty, 0);
const got = o.receipts.reduce((t, rc) => t + rc.lines.reduce((n, l) => n + l.qty, 0), 0);
const total = orderTotal(onScreen, byId);
const ev: { date: string; what: string; sub: string; photoId?: string | null }[] = [{ date: o.date, what: "Order created", sub: o.replenish ? "Auto-built replenishment draft" : o.source }];
if (o.status !== "Draft" && o.status !== "Cancelled") ev.push({ date: o.date, what: "Placed with " + onScreen.supplier + (onScreen.ref ? " — ref " + onScreen.ref : ""), sub: "" });
for (const rc of o.receipts) ev.push({ photoId: rc.photoId, date: rc.date, what: "Delivery received" + (rc.invoice ? " — invoice " + rc.invoice : ""), sub: rc.lines.map((x) => `${label(byId[x.itemId])} ${x.size} ×${x.qty}${x.dest === "pickup" ? " → pickup" : " → shelf"}`).join(", ") + (rc.note ? " · " + rc.note : "") });
if (o.status === "Cancelled") ev.push({ date: "", what: "Order cancelled", sub: "" });
const backOrders = s.orders.filter((x) => x.parentId === o.id);
const parent = o.parentId ? s.orders.find((x) => x.id === o.parentId) : undefined;
function printPO() {
const sp = supplierInfo(s, onScreen.supplier);
const rows = onScreen.lines.map((l) => { const it = byId[l.itemId]; return `<tr><td>${esc(label(it))}</td><td>${esc(it?.sku || "—")}</td><td>${esc(l.size)}</td><td class="r">${l.qty}</td><td class="r">${esc(money(unitOf(l)))}</td><td class="r">${esc(money(lineAmt[l.id]))}</td></tr>`; }).join("");
const css = ".hd{display:flex;justify-content:space-between;align-items:flex-end;border-bottom:2px solid #201e1d;padding-bottom:8px}.hd h1{border:none;padding:0;font-size:20px}.meta2{display:grid;grid-template-columns:1fr 1fr;gap:4px 24px;margin:12px 0;font-size:12px;line-height:1.7}.tot{text-align:right;font-size:16px;font-weight:800;margin-top:10px}.notes{margin-top:14px;font-size:12px;color:#444}";
const body = `<div class="hd"><h1><span class="sq"></span>Purchase order — ${esc(onScreen.code)}</h1><div style="font-size:12px">${esc(s.settings.facility)} · ${esc(s.settings.location)}</div></div>` +
`<div class="meta2"><div>Supplier: <b>${esc(onScreen.supplier)}${sp && (sp.contact || sp.phone) ? " · " + esc([sp.contact, sp.phone].filter(Boolean).join(" · ")) : ""}</b></div><div>Date: <b>${esc(fmtDate(onScreen.date || s.today))}</b></div><div>Supplier ref: <b>${esc(onScreen.ref || "—")}</b></div><div>Expected: <b>${esc(onScreen.expected ? fmtDate(onScreen.expected) : "—")}</b></div><div>Account: <b>${esc(sp?.account || "—")}</b> · ${esc(forLabel)}</div><div>Cost centre: <b>${esc(ccCode || "—")}</b></div></div>` +
`<table><tr><th>Item</th><th>SKU</th><th>Size</th><th class="r">Qty</th><th class="r">Unit</th><th class="r">Total</th></tr>${rows}</table><div class="tot">Total ${esc(money(orderTotal(onScreen, byId)))}</div>` +
(onScreen.notes ? `<div class="notes">Notes: ${esc(onScreen.notes)}</div>` : "") + `<div class="notes">Ordered by ____________________ &nbsp;&nbsp; Date ____________</div>`;
openPrintWindow(onScreen.code, body, { page: "size:A4;margin:16mm", css, width: 780, height: 920 });
}
function exportCsv() {
downloadCsv((onScreen.code + (onScreen.ref ? "-" + onScreen.ref.replace(/[^A-Za-z0-9-]+/g, "_") : "")).toLowerCase() + ".csv", `Order,${csvEsc(onScreen.code)}\nSupplier,${csvEsc(onScreen.supplier)}\nRef,${csvEsc(onScreen.ref)}\n\n` + csvOf(["Item", "Supplier code", "SKU", "Size", "Qty", "Unit cost", "Total"], onScreen.lines.map((l) => { const it = byId[l.itemId]; return [label(it), supplierCodeOf(s, key(l.itemId, it ? it.sizes.map(String).indexOf(l.size) : -1)), it?.sku || "", l.size, l.qty, +unitOf(l).toFixed(2), lineAmt[l.id].toFixed(2)]; })));
}
async function emailSupplier() {
setMailMsg("Sending…");
const r = await mutate<{ sentTo: string }>("order.email", { id: o!.id });
setMailMsg(r.ok ? `Sent to ${r.result.sentTo}` : r.error);
}
async function duplicate() {
if (!(await flushQty())) return;
const r = await mutate<{ id: string }>("order.duplicate", { id: o!.id });
if (!r.ok) { setErr(r.error); return; }
router.push(`/app/orders/${r.result.id}`);
}
return (
<section>
<OrdersStyles />
<PageHead
title={o.code}
sub={<>{forLabel} · {onScreen.supplier} · placed {fmtDate(o.date)}{o.replenish ? " · replenishment" : ""}{parent && <> · back order of <Link href={`/app/orders/${parent.id}`} style={{ color: "inherit" }}>{parent.code}</Link></>}</>}
below={<div style={{ display: "flex", gap: 6, marginTop: 8, flexWrap: "wrap" }}>{overdue && <Tag tone="accent">Overdue</Tag>}<span className={statusTag(o.status)}>{o.status}</span></div>}
>
{o.status === "Draft" && isAdmin && <button type="button" className="btn btn-primary" onClick={async () => { if (await flushQty()) act("order.status", { id: o.id, status: "Ordered" }); }} disabled={o.lines.length === 0}>Mark ordered</button>}
{["Ordered", "Shipped", "Back Order"].includes(o.status) && <button type="button" className="btn btn-primary" onClick={async () => { if (await flushQty()) setRcv(true); }}>Receive delivery</button>}
{isAdmin && <a className="btn btn-onink" href={`/print/supplier-order?id=${o.id}`} target="_blank" rel="noreferrer" title="The A4 sheet with the supplier's product codes" onClick={() => { void mutate("order.printed", { id: o.id }); }}>Order sheet</a>}
<MoreMenu tone="ink" items={[
{ label: "Print order", onSelect: printPO },
{ label: "CSV", onSelect: exportCsv },
{ label: "Email supplier", onSelect: emailSupplier, hidden: !(isAdmin && o.status !== "Draft" && o.status !== "Cancelled") },
{ label: "Mark shipped", onSelect: () => { void act("order.status", { id: o.id, status: "Shipped" }); }, hidden: !(isAdmin && ["Ordered", "Back Order"].includes(o.status)) },
{ label: "Duplicate", onSelect: () => { void duplicate(); } },
{ label: "Cancel order", danger: true, hidden: !(isAdmin && ["Draft", "Ordered", "Back Order", "Shipped"].includes(o.status)), onSelect: async () => { if (confirm(`Cancel ${o.code}?`) && await flushQty()) act("order.status", { id: o.id, status: "Cancelled" }); } },
]} />
</PageHead>
<Crumb href="/app/orders/all" parent="Orders" current={o.code} />
<LiveRegion tone="alert" className="notice tc-flag" msg={err} style={{ marginBottom: 16, color: "var(--color-accent-700)", fontWeight: 700 }} />
<LiveRegion msg={mailMsg} className="notice" style={{ marginBottom: 16 }} />
<Figures items={[
{ value: money(total), label: "Order value", note: <span className="tc-mono">{onScreen.lines.length} line{onScreen.lines.length === 1 ? "" : "s"}</span> },
{ value: `${got} of ${units}`, label: "Units received" },
{ value: onScreen.expected ? fmtDate(onScreen.expected) : "—", label: "Expected", flag: overdue, note: overdue ? <span className="tc-mono">{daysBetween(onScreen.expected, s.today)} day{daysBetween(onScreen.expected, s.today) === 1 ? "" : "s"} overdue</span> : undefined },
]} />
<div className="tc-orders-detail">
<div style={{ display: "flex", flexDirection: "column", gap: 24, minWidth: 0 }}>
<Panel title="Order details" aside="Saves as you type">
<div className="tc-orders-fields">
{([["ref", "Supplier order no.", "text", "e.g. NW-48211"], ["invoice", "Invoice no.", "text", "e.g. INV-102938"], ["tracking", "Tracking no.", "text", "e.g. 34XY990812"], ["expected", "Expected delivery", "date", ""]] as const).map(([k, lbl, type, ph]) => (
<Field key={k} label={lbl}>{(c) => <input {...c} className="input" type={type} placeholder={ph} value={val(k)} onChange={(e) => saveField(k, e.target.value)} />}</Field>
))}
<Field label="Supplier">
{(c) => s.settings.suppliers.length ? <select {...c} className="input" value={val("supplier")} onChange={(e) => saveField("supplier", e.target.value)}>{[...new Set([o.supplier, ...s.settings.suppliers])].filter(Boolean).map((x) => <option key={x}>{x}</option>)}</select> : <input {...c} className="input" value={val("supplier")} onChange={(e) => saveField("supplier", e.target.value)} />}
</Field>
<Field label="Order for">
{(c) => (
<select {...c} className="input" value={o.staffId || ""} onChange={(e) => act("order.update", { id: o.id, staffId: e.target.value })}>
<option value="">Stock (linen room)</option>
{s.staff.filter((x) => !x.inactive || x.id === o.staffId).map((x) => <option key={x.id} value={x.id}>{x.first} {x.last} ({x.num})</option>)}
</select>
)}
</Field>
<Field label="Cost centre">
{(c) => (
<select {...c} className="input" value={val("cc") || (st ? st.dept : "")} onChange={(e) => act("order.update", { id: o.id, cc: e.target.value })}>
<option value=""> none </option>
{s.depts.map((d) => <option key={d.id} value={d.name}>{d.name}{d.cc ? ` (${d.cc})` : ""}</option>)}
{o.cc && !s.depts.find((d) => d.name === o.cc) && <option value={o.cc}>{o.cc}{ccFor(s, o.cc) ? "" : " (code)"}</option>}
</select>
)}
</Field>
{ccNote && <div style={{ gridColumn: "1 / -1", fontSize: 12, color: "var(--color-neutral-700)", borderLeft: "4px solid var(--color-text)", paddingLeft: "var(--space-2)" }}>{ccNote}</div>}
<Field label="Notes" style={{ gridColumn: "1 / -1" }}>{(c) => <input {...c} className="input" placeholder="e.g. rang the supplier re back order 12/8" value={val("notes")} onChange={(e) => saveField("notes", e.target.value)} />}</Field>
</div>
</Panel>
<div>
<Panel title="Lines" aside={<span className="tc-mono">{units} unit{units === 1 ? "" : "s"} ordered</span>}
foot={<><span className="tc-lbl">Total</span><span className="tc-mono" style={{ marginLeft: "auto", fontSize: 18, fontWeight: 600 }}>{money(total)}</span></>}>
<div>
{onScreen.lines.map((l) => {
const it = byId[l.itemId]; const rec = received(l.itemId, l.size); const cKey = l.id;
const catCost = it ? it.cost : 0;
// What this line is actually worth per unit once a delivery has been invoiced.
const unit = unitOf(l);
return (
<div key={l.id} className="tc-orders-row" style={{ fontSize: 13 }}>
<div className="tc-orders-rowmain" style={{ minWidth: 150 }}>
<div style={{ fontWeight: 600 }}>{label(it)} · <span className="tc-mono">{l.size}</span></div>
<div className="tc-orders-rowmeta">{supplierCodeOf(s, key(l.itemId, it ? it.sizes.map(String).indexOf(l.size) : -1)) && <span className="tc-mono">{supplierCodeOf(s, key(l.itemId, it ? it.sizes.map(String).indexOf(l.size) : -1))}</span>}{rec ? <> · received <span className="tc-mono">{rec}</span></> : ""}{Math.abs(unit - catCost) > 0.004 && <span title={`Delivered units are priced at what the invoice charged: ${money(unit)} a unit across this line`}> · invoice price</span>}</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)", flex: "none" }}>
{o.status === "Draft"
? <QtyStepper size="sm" min={1} value={l.qty} label={`${label(it)} size ${l.size}`} onChange={(n) => bumpQty(l.id, l.qty, n - l.qty)} />
: <span className="tc-mono" style={{ fontWeight: 600 }}>×{l.qty}</span>}
<span className="tc-mono">@ $</span>
<input className="input tc-mono" style={{ minHeight: 28, padding: "2px 8px", width: 70, textAlign: "right" }} inputMode="decimal" aria-label={`Unit cost of ${label(it)} size ${l.size}`} title={isAdmin ? "Editing a price updates that item's catalogue cost everywhere." : "Prices are set by an admin."} disabled={!isAdmin} value={priceDraft[cKey] !== undefined ? priceDraft[cKey] : String(catCost)}
onChange={(e) => setPriceDraft({ ...priceDraft, [cKey]: e.target.value.replace(/[^0-9.]/g, "") })}
onBlur={async () => { const v = parseFloat(priceDraft[cKey]); if (!isNaN(v) && v >= 0 && Math.abs(v - catCost) > 0.004 && it) { await act("catalog.update", { id: it.id, cost: v }); } const d = { ...priceDraft }; delete d[cKey]; setPriceDraft(d); }} />
{o.status === "Draft" && o.lines.length > 1 && <button type="button" className="btn btn-ghost" style={{ minHeight: 26, padding: "0 4px" }} aria-label={`Remove ${label(it)} size ${l.size} from this order`} title="Remove" onClick={() => removeLine(l.id)}>×</button>}
</div>
<div className="tc-mono" style={{ minWidth: 86, textAlign: "right", fontWeight: 500 }}>{money(l.qty * unit)}</div>
</div>
);
})}
</div>
{o.status === "Draft" && (
<div className="tc-orders-add">
<span className="tc-lbl" style={{ alignSelf: "center" }}>Add a line</span>
<ItemSizePicker s={s} itemId={pick} onItem={setPick} placeholder="Choose an item…" maxWidth={280} onSize={async (it, si) => { if (await flushQty()) act("order.lineAdd", { id: o.id, itemId: it.id, size: it.sizes[si], qty: 1 }); }} />
</div>
)}
</Panel>
{backOrders.length > 0 && <div className="tc-orders-rowmeta" style={{ marginTop: 8 }}>Back order{backOrders.length > 1 ? "s" : ""}: {backOrders.map((b) => <Link key={b.id} href={`/app/orders/${b.id}`} className="tc-mono" style={{ marginRight: 8 }}>{b.code}</Link>)}</div>}
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 24, minWidth: 0 }}>
<Panel title="History">
{ev.map((e, i) => (
<div key={i} className="tc-orders-row" style={{ alignItems: "flex-start", flexWrap: "nowrap" }}>
<div className="tc-mono" style={{ minWidth: 92, flex: "none", paddingTop: 1, fontSize: 12, color: "#57534f" }}>{e.date ? fmtDate(e.date) : "—"}</div>
<div className="tc-orders-rowmain">
<div style={{ fontWeight: 600 }}>{e.what}{e.photoId && <button type="button" className="btn btn-ghost" style={{ minHeight: 22, padding: "0 6px", marginLeft: 8 }} onClick={() => viewPhoto(e.photoId!)}>Invoice photo</button>}</div>
{e.sub && <div className="tc-orders-rowmeta">{e.sub}</div>}
</div>
</div>
))}
</Panel>
{st && (
<Panel title="Staff member">
<div style={{ fontSize: 13, lineHeight: 1.7, padding: "12px 16px" }}>
<div style={{ fontWeight: 600 }}><Link href={`/app/staff/${st.id}`} className="link-name">{staffName(st)}</Link> <span className="tc-mono" style={{ fontWeight: 400, color: "var(--color-neutral-700)" }}>{st.num}</span></div>
<div>{st.dept} · {st.phone || "no phone"}</div>
</div>
</Panel>
)}
</div>
</div>
{rcv && <ReceiveDialog order={onScreen} onClose={() => { setRcv(false); router.refresh(); }} />}
</section>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { Field, PageHead } from "@/components/ui";
import { Panel, Seg, SelectButton, Tag } from "@/components/portal";
import { ccOfOrder, csvOf, daysBetween, fmtDate, isOverdue, isPlacedOpen, label, money, orderTotal, staffName, statusTag } from "@/lib/compute";
import { downloadCsv } from "@/lib/print";
import { Crumb, NewOrder, OrdersStyles, shortDate } from "@/components/orders/bits";
const STATUSES = ["All", "Draft", "Open", "Received"] as const;
/* Every order: the filters, the status segment and the CSV that used to sit on the Ordering screen. */
export default function OrderLedgerPage() {
const { s } = useSnap();
const { byId, staffById } = useDerived();
const [dlg, setDlg] = useState(false);
const [q, setQ] = useState("");
const [sup, setSup] = useState("");
const [status, setStatus] = useState<(typeof STATUSES)[number]>("All");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [item, setItem] = useState("");
const supOpts = useMemo(() => [{ value: "", label: "All suppliers" }, ...[...new Set(s.orders.map((o) => o.supplier).filter(Boolean))].sort().map((x) => ({ value: x, label: x }))], [s.orders]);
const itemOpts = useMemo(() => {
const ids = new Set<string>();
for (const o of s.orders) for (const l of o.lines) ids.add(l.itemId);
return [{ value: "", label: "All garments" }, ...[...ids].map((id) => byId[id]).filter(Boolean).sort((a, b) => label(a).localeCompare(label(b))).map((it) => ({ value: it.id, label: label(it) }))];
}, [s.orders, byId]);
const rank = (o: (typeof s.orders)[number]) => (o.status === "Draft" ? 0 : isPlacedOpen(o) ? 1 : o.status === "Received" ? 2 : 3);
const ql = q.trim().toLowerCase();
const orders = s.orders.filter((o) => {
if (sup && o.supplier !== sup) return false;
if (status === "Draft" && o.status !== "Draft") return false;
if (status === "Open" && !isPlacedOpen(o)) return false;
if (status === "Received" && o.status !== "Received") return false;
if (from && o.date < from) return false;
if (to && o.date > to) return false;
if (item && !o.lines.some((l) => l.itemId === item)) return false;
if (ql) {
const st = o.staffId ? staffById[o.staffId] : undefined;
const hay = `${o.code} ${o.ref} ${o.invoice} ${o.tracking} ${o.supplier} ${staffName(st)} ${o.lines.map((l) => label(byId[l.itemId])).join(" ")}`.toLowerCase();
if (!hay.includes(ql)) return false;
}
return true;
}).sort((a, b) => rank(a) - rank(b) || (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
const narrowed = ql !== "" || sup !== "" || status !== "All" || from !== "" || to !== "" || item !== "";
/* The rows on screen, filters applied. Value is orderTotal() so the file agrees with the screen and
Reports; dates stay ISO so a spreadsheet sorts them; notes stay out. */
function exportCsv() {
const cols = ["Order no.", "Status", "Ordered", "Supplier", "Ordered for", "Staff member", "Supplier ref", "Invoice", "Tracking", "Cost centre", "Replenishment", "Expected", "Days overdue", "Received", "Lines", "Units ordered", "Units received", "Value"];
downloadCsv(`threadcount-orders-${s.today}.csv`, csvOf(cols, orders.map((o) => {
const st = o.staffId ? staffById[o.staffId] : undefined;
const units = o.lines.reduce((t, l) => t + l.qty, 0);
const got = o.receipts.reduce((t, rc) => t + rc.lines.reduce((n, l) => n + l.qty, 0), 0);
return [o.code, o.status, o.date, o.supplier, o.orderFor === "Stock" ? "Stock" : "Staff member", staffName(st), o.ref, o.invoice, o.tracking, ccOfOrder(s, o, staffById), o.replenish ? "Yes" : "No", o.expected, isOverdue(o, s.today) ? daysBetween(o.expected, s.today) : "", o.received, o.lines.length, units, got, +orderTotal(o, byId).toFixed(2)];
})));
}
return (
<section>
<OrdersStyles />
<PageHead title="All orders" sub={<span className="tc-mono">{orders.length} of {s.orders.length}</span>}>
<button type="button" className="btn btn-onink" onClick={exportCsv} disabled={orders.length === 0}>{narrowed ? `Export CSV (${orders.length} shown)` : "Export CSV"}</button>
<button type="button" className="btn btn-primary" onClick={() => setDlg(true)}>New order</button>
</PageHead>
<Crumb href="/app/orders" parent="Orders" current="All orders" />
<div className="tc-orders-filters">
<input className="input" style={{ width: 240 }} aria-label="Search orders by number, reference or invoice" placeholder="Order no., ref, invoice" value={q} onChange={(e) => setQ(e.target.value)} />
<SelectButton label="Supplier" value={sup} options={supOpts} onChange={setSup} anyValue="" />
<SelectButton label="Garment" value={item} options={itemOpts} onChange={setItem} anyValue="" />
<Field label="From">{(c) => <input {...c} className="input" style={{ width: 150 }} type="date" value={from} onChange={(e) => setFrom(e.target.value)} />}</Field>
<Field label="To">{(c) => <input {...c} className="input" style={{ width: 150 }} type="date" value={to} onChange={(e) => setTo(e.target.value)} />}</Field>
<Seg label="Status" opts={STATUSES} value={status} onChange={setStatus} />
</div>
<Panel title="Orders" aside={`${orders.length} shown`}>
{orders.length === 0 && <div className="tc-orders-row"><span className="tc-orders-rowmeta">{s.orders.length === 0 ? "No orders yet." : "No orders match."}</span></div>}
{orders.map((o) => {
const st = o.staffId ? staffById[o.staffId] : undefined;
const overdue = isOverdue(o, s.today);
const late = overdue ? daysBetween(o.expected, s.today) : 0;
const due = overdue
? late === 1 ? "due yesterday" : `${late} days overdue`
: isPlacedOpen(o) && o.expected ? (o.expected === s.today ? "due today" : `due ${shortDate(o.expected)}`) : "";
return (
<Link key={o.id} href={`/app/orders/${o.id}`} className={"tc-orders-row" + (overdue ? " urgent" : "")}>
<div className="tc-orders-rowmain" style={{ minWidth: 200 }}>
<div className="tc-orders-rowtitle">{o.code}</div>
<div className="tc-orders-rowmeta" title={fmtDate(o.date)}>
{o.orderFor === "Stock" ? "For stock" : "For " + staffName(st, "staff member")} · {o.supplier} · {shortDate(o.date)}{o.ref ? " · ref " + o.ref : ""}
{due && <> · <span style={{ color: "var(--color-accent-700)", fontWeight: 600 }}>{due}</span></>}
</div>
</div>
{o.replenish && <Tag>Replenishment</Tag>}
{overdue && <Tag tone="accent">Overdue</Tag>}
<span className={statusTag(o.status)}>{o.status}</span>
<span className="tc-mono" style={{ minWidth: 90, textAlign: "right", fontWeight: 500 }}>{money(orderTotal(o, byId))}</span>
<span aria-hidden="true" style={{ fontSize: 12, color: "#6c6764" }}></span>
</Link>
);
})}
</Panel>
{dlg && <NewOrder onClose={() => setDlg(false)} />}
</section>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { redirect } from "next/navigation";
/* The order list is now the To order column on /app/orders. next.config.ts redirects as well; this
* stub keeps an old bookmark working if that map ever changes. */
export default async function OrderListPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
const sp = await searchParams;
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(sp)) for (const x of Array.isArray(v) ? v : v === undefined ? [] : [v]) qs.append(k, x);
const q = qs.toString();
redirect(q ? `/app/orders?${q}` : "/app/orders");
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useState } from "react";
import { useSnap } from "@/lib/client";
import { PageHead } from "@/components/ui";
import ToOrder from "@/components/orders/ToOrder";
import OnTheWay from "@/components/orders/OnTheWay";
import ThisMonth from "@/components/orders/ThisMonth";
import RecentOrders from "@/components/orders/RecentOrders";
import { NewOrder, OrdersStyles } from "@/components/orders/bits";
export default function OrdersPage() {
const { isAdmin } = useSnap();
const [dlg, setDlg] = useState<null | "stock" | "staff">(null);
return (
<section>
<OrdersStyles />
<PageHead title="Orders">
<button type="button" className="btn btn-onink" onClick={() => setDlg("staff")}>Order for a person</button>
<button type="button" className="btn btn-primary" onClick={() => setDlg("stock")}>New order</button>
</PageHead>
<div className="tc-orders-grid">
<div className="tc-orders-toorder">{isAdmin ? <ToOrder /> : <RecentOrders />}</div>
<div className="tc-orders-otw"><OnTheWay /></div>
<div className="tc-orders-month"><ThisMonth /></div>
</div>
{dlg && <NewOrder onClose={() => setDlg(null)} initOrderFor={dlg === "staff" ? "Staff Member" : undefined} />}
</section>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import Link from "next/link";
import { PageHead, Empty } from "@/components/ui";
import { usePortalCounts } from "@/lib/portalcounts";
import { todayHeadLine } from "@/lib/today";
import SetupGroup from "@/components/today/SetupGroup";
import CollectGroup from "@/components/today/CollectGroup";
import RoundGroup from "@/components/today/RoundGroup";
import PickGroup from "@/components/today/PickGroup";
import ReceiveGroup from "@/components/today/ReceiveGroup";
import CountsGroup from "@/components/today/CountsGroup";
import RunsOutPanel from "@/components/today/RunsOutPanel";
import MonthEndPanel from "@/components/today/MonthEndPanel";
/* Today: the work queue. Each group is a thing somebody has to go and do, and a group with nothing
in it is not drawn. Membership and the head count both come from lib/portalcounts.ts, the same
numbers the rail badge shows. */
export default function TodayPage() {
const { today } = usePortalCounts();
const column: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 18, minWidth: 0 };
return (
<section>
<PageHead title="Today" sub={todayHeadLine(today.total, today.overdue)}>
<Link href="/app/counter" className="btn btn-primary">Open the counter</Link>
</PageHead>
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.75fr) minmax(0, 1fr)", gap: 24, alignItems: "start", marginTop: 24 }}>
<div style={column}>
<SetupGroup />
<CollectGroup />
<RoundGroup />
<PickGroup />
<ReceiveGroup />
<CountsGroup />
{today.total === 0 && <Empty pad={2}>Nothing in the queue.</Empty>}
</div>
<div style={column}>
<RunsOutPanel />
<MonthEndPanel />
</div>
</div>
</section>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useSnap } from "@/lib/client";
import { monthLabel } from "@/lib/compute";
import { PageHead } from "@/components/ui";
import { Icon, Seg } from "@/components/portal";
import { useReportData } from "@/components/reports/useReportData";
import MonthEndStrip from "@/components/reports/MonthEndStrip";
import SpendTab, { JOURNAL_ID } from "@/components/reports/SpendTab";
import StockTab from "@/components/reports/StockTab";
import PeopleTab from "@/components/reports/PeopleTab";
const TABS = ["spend", "stock", "people"] as const;
type Tab = (typeof TABS)[number];
const LABELS: Record<Tab, string> = { spend: "Spend", stock: "Stock", people: "People" };
/* The nine reports' old names, so a link to one of them still lands on the tab that holds it. */
const LEGACY: Record<string, Tab> = {
overview: "spend", journal: "spend",
valuation: "stock", shrinkage: "stock", "top-stock": "stock", topstock: "stock", suppliers: "stock",
exceptions: "people", approvals: "people", "pre-loved": "people", preloved: "people",
};
/* Screen-local layout. Scoped to .tc-rep so nothing leaks outside this screen. */
export default function ReportPage() {
return <Suspense fallback={null}><ReportInner /></Suspense>;
}
function ReportInner() {
const { s } = useSnap();
const router = useRouter();
const sp = useSearchParams();
const thisMonth = s.today.slice(0, 7);
const rawTab = (sp.get("tab") || "").toLowerCase();
const tab: Tab = (TABS as readonly string[]).includes(rawTab) ? (rawTab as Tab) : LEGACY[rawTab] ?? "spend";
const rawMonth = sp.get("month") || "";
const month = /^\d{4}-(0[1-9]|1[0-2])$/.test(rawMonth) ? rawMonth : thisMonth;
const d = useReportData(month);
const [jump, setJump] = useState(false);
function go(next: { tab?: Tab; month?: string }) {
const t = next.tab ?? tab, m = next.month ?? month;
const q = new URLSearchParams();
q.set("tab", t);
if (m !== thisMonth) q.set("month", m);
router.replace(`/app/report?${q.toString()}`, { scroll: false });
}
useEffect(() => {
if (!jump || tab !== "spend") return;
document.getElementById(JOURNAL_ID)?.scrollIntoView({ block: "start" });
setJump(false);
}, [jump, tab]);
const exportCsv = tab === "spend" ? d.csv.overview : tab === "stock" ? d.csv.valuation : d.csv.exceptions;
return (
<section className="tc-rep">
<PageHead title="Reports">
<span className="tc-selectbtn btn btn-onink" style={{ gap: 10 }}>
<span aria-hidden="true">{monthLabel(month)}</span>
<Icon name="chevronDown" size={16} />
<select aria-label="Reporting month" value={month} onChange={(e) => go({ month: e.target.value })}>
{d.R.repMonths.map((m) => <option key={m} value={m}>{monthLabel(m)}</option>)}
</select>
</span>
<button type="button" className="btn btn-onink" onClick={exportCsv}>Export CSV</button>
<button type="button" className="btn btn-primary" onClick={d.printEomPack}>Month-end pack</button>
</PageHead>
<div className="tc-rep-stack">
<MonthEndStrip month={month} onPrint={d.printEomPack} onJournal={() => { setJump(true); if (tab !== "spend") go({ tab: "spend" }); }} />
<div>
<Seg label="Report" opts={TABS} value={tab} labels={LABELS} onChange={(t) => go({ tab: t })} />
</div>
{tab === "spend" && <SpendTab d={d} onMonth={(m) => go({ month: m })} />}
{tab === "stock" && <StockTab d={d} />}
{tab === "people" && <PeopleTab d={d} />}
</div>
</section>
);
}
+142
View File
@@ -0,0 +1,142 @@
"use client";
/* The full request queue: /app/requests?filter=&staff=&ward=&open=
*
* Approval is the ward's and fulfilment is the linen room's; this screen is the linen room's side.
* The rows come from components/requests/RequestList, the same list the staff record and Today
* use. One payload is fetched and every filter, count and export is a narrowing of it. */
import { Suspense, useEffect, useMemo } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useSnap } from "@/lib/client";
import { Empty, ErrorLine, PageHead } from "@/components/ui";
import { MonoNum, Seg } from "@/components/portal";
import RequestList, {
REQUEST_FILTERS, REQUEST_FILTER_LABEL, RequestStyles, isRowFilter, requestCounts, requestsFor, scopePayload,
useRequestActions, useRequests, type RequestFilter,
} from "@/components/requests/RequestList";
import Queries from "@/components/requests/Queries";
import Damage from "@/components/requests/Damage";
import KitCheck from "@/components/requests/KitCheck";
import { exportCount, exportRequestsCsv } from "@/components/requests/csv";
// useSearchParams needs a Suspense boundary for static rendering.
export default function RequestsPage() {
return <Suspense fallback={null}><RequestsInner /></Suspense>;
}
const isFilter = (v: string | null): v is RequestFilter => !!v && (REQUEST_FILTERS as readonly string[]).includes(v);
function RequestsInner() {
const { s } = useSnap();
const router = useRouter();
const pathname = usePathname();
const sp = useSearchParams();
const rawFilter = sp.get("filter");
const staffId = sp.get("staff") || undefined;
const ward = sp.get("ward") || undefined;
const openId = sp.get("open");
const { data, error, reload } = useRequests();
const { act, error: actError } = useRequestActions(reload);
const person = staffId ? s.staff.find((x) => x.id === staffId) : undefined;
const scope = useMemo(() => ({ staffId, ward }), [staffId, ward]);
const scoped = useMemo(() => (data ? scopePayload(data, scope, person?.num) : null), [data, scope, person?.num]);
const counts = useMemo(() => (scoped ? requestCounts(scoped) : null), [scoped]);
/* A deep link to one request with no filter named lands on the first filter that holds it, so
?open= always shows the row expanded. */
const filter: RequestFilter = useMemo(() => {
if (isFilter(rawFilter)) return rawFilter;
if (openId && scoped) {
for (const f of ["todo", "open", "all"] as const) if (requestsFor(scoped.requests, f).some((r) => r.id === openId)) return f;
}
return "todo";
}, [rawFilter, openId, scoped]);
function setParams(next: Record<string, string | null>) {
const q = new URLSearchParams(sp.toString());
for (const [k, v] of Object.entries(next)) { if (v === null) q.delete(k); else q.set(k, v); }
const qs = q.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
}
// An unknown ?filter= is dropped rather than left in the address bar disagreeing with the screen.
useEffect(() => {
if (!rawFilter || isFilter(rawFilter)) return;
const q = new URLSearchParams(window.location.search);
q.delete("filter");
const qs = q.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
}, [rawFilter, router, pathname]);
const showing = staffId ? (person ? `${person.first} ${person.last}`.trim() : data?.requests.find((r) => r.staffId === staffId)?.staffName || "one person") : ward;
const more = data?.moreRequests ? "+" : "";
const segCounts = counts
? Object.fromEntries(REQUEST_FILTERS.map((f) => [f, `${counts[f]}${isRowFilter(f) ? more : ""}`])) as Record<RequestFilter, string>
: undefined;
const shown = scoped ? exportCount(filter, scoped) : 0;
const narrowed = !!showing || (filter !== "all" && filter !== "cycles");
return (
<section>
<RequestStyles />
<PageHead title="Requests">
<button type="button" className="btn btn-ghost" disabled={!scoped || shown === 0}
onClick={() => { if (scoped) exportRequestsCsv(s, filter, scoped, showing); }}>
{narrowed && filter !== "cycles" ? `Export CSV (${shown} shown)` : "Export CSV"}
</button>
</PageHead>
{showing && (
<div className="tc-req-actions" style={{ marginBottom: 12 }}>
<span className="tc-meta-line" style={{ fontSize: 13 }}>Showing <b style={{ color: "var(--color-text)" }}>{showing}</b></span>
<button type="button" className="btn btn-ghost" style={{ minHeight: 30, padding: "2px 8px" }}
aria-label={`Clear, show every ${staffId ? "person" : "ward"}`}
onClick={() => setParams(staffId ? { staff: null, open: null } : { ward: null, open: null })}>
Clear
</button>
</div>
)}
{!data ? (
error ? (
<>
<ErrorLine msg={error} />
<div style={{ marginTop: 12 }}><button type="button" className="btn btn-secondary" onClick={() => void reload()}>Try again</button></div>
</>
) : <Empty>Loading</Empty>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
{error && <ErrorLine msg={error} />}
{counts && counts.noapprover > 0 && filter !== "noapprover" && (
<div className="tc-flag tc-req-actions" style={{ border: "2px solid var(--color-text)", borderLeft: "4px solid var(--color-accent)", padding: "10px 16px", justifyContent: "space-between" }}>
<b style={{ fontSize: 13.5 }}>
<span className="tc-mark" aria-hidden="true" />
<MonoNum weight={600} tone="accent" size={15}>{counts.noapprover}{more}</MonoNum> with no approver
</b>
<button type="button" className="btn btn-secondary" onClick={() => setParams({ filter: "noapprover" })}>Address them</button>
</div>
)}
<div className="tc-req-scroll">
<Seg label="Which requests" opts={REQUEST_FILTERS} labels={REQUEST_FILTER_LABEL} counts={segCounts} value={filter}
onChange={(f) => setParams({ filter: f })} style={{ flexWrap: "nowrap", width: "max-content" }} />
</div>
{actError && <ErrorLine msg={actError} />}
{isRowFilter(filter) ? (
<RequestList filter={filter} openId={openId} data={scoped} reload={reload} />
) : filter === "queries" ? (
<Queries rows={scoped?.disputes ?? []} act={act} />
) : filter === "damage" ? (
<Damage rows={scoped?.damage ?? []} act={act} />
) : (
<KitCheck cycle={scoped?.cycle ?? null} shortfalls={scoped?.shortfalls ?? []} waiting={scoped?.waiting ?? []} act={act} />
)}
</div>
)}
</section>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { PageHead, Empty, LiveRegion } from "@/components/ui";
import { Panel, QueueRow, Seg } from "@/components/portal";
import { DeliverDialog } from "@/components/dialogs";
import type { PickupRec } from "@/lib/compute";
import { plural, roundSheet } from "@/lib/today";
import { Lines } from "@/components/today/Lines";
// Delivery rounds: every uncollected pickup by ward, handed over on the floor with an on-screen
// signature and a handover photo (DeliverDialog).
const ALL = "__all__";
export default function RoundsPage() {
const { s } = useSnap();
const { byId, staffById } = useDerived();
const [deliver, setDeliver] = useState<PickupRec | null>(null);
const [msg, setMsg] = useState("");
const [ward, setWard] = useState<string>(ALL);
useEffect(() => { const w = new URLSearchParams(window.location.search).get("ward"); if (w) setWard(w); }, []);
const sheet = useMemo(() => roundSheet(s, byId, staffById), [s, byId, staffById]);
const bags = sheet.reduce((t, w) => t + w.rows.length, 0);
const garments = sheet.reduce((t, w) => t + w.garments, 0);
const current = ward !== ALL && sheet.some((w) => w.ward === ward) ? ward : ALL;
const shown = current === ALL ? sheet : sheet.filter((w) => w.ward === current);
function choose(w: string) {
setWard(w);
const u = new URL(window.location.href);
if (w === ALL) u.searchParams.delete("ward"); else u.searchParams.set("ward", w);
window.history.replaceState(null, "", u.pathname + u.search);
}
const opts = [ALL, ...sheet.map((w) => w.ward)];
const labels: Record<string, string> = { [ALL]: "All" };
const counts: Record<string, number> = { [ALL]: bags };
for (const w of sheet) counts[w.ward] = w.rows.length;
return (
<section>
<PageHead title="Delivery rounds" sub={<span className="tc-mono">{plural(bags, "bag")} · {plural(sheet.length, "ward")} · {plural(garments, "garment")}</span>} />
<LiveRegion msg={msg} style={{ marginTop: 16, fontSize: 13, fontWeight: 600 }} />
{sheet.length > 1 && (
<div style={{ marginTop: 16, overflowX: "auto" }}>
<Seg label="Ward" opts={opts} value={current} onChange={choose} labels={labels} counts={counts} />
</div>
)}
{bags === 0 && <Empty>Nothing waiting for delivery.</Empty>}
<div style={{ display: "flex", flexDirection: "column", gap: 18, marginTop: 18 }}>
{shown.map((w) => (
<Panel key={w.ward} title={w.ward} aside={<span className="tc-mono">{w.cc || "—"} · {w.rows.length} to deliver</span>}>
{w.rows.map((r) => (
<QueueRow
key={r.p.id}
age={`${r.days}d`}
ageLabel="waiting"
urgent={r.late}
title={r.name}
titleMeta={r.phone ? (r.tel ? <a href={r.tel} style={{ color: "inherit" }}>{r.phone}</a> : r.phone) : undefined}
meta={<><Lines lines={r.lines} /> · {r.p.orderCode}</>}
actions={<button type="button" className="btn btn-primary" aria-label={`Sign for the delivery to ${r.name}`} onClick={() => setDeliver(r.p)}>Delivered sign</button>}
/>
))}
</Panel>
))}
</div>
{deliver && <DeliverDialog pickup={deliver} onClose={() => setDeliver(null)} onDone={(m) => setMsg(m)} />}
</section>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
/* Settings: seven sections in a side list, each a ?tab= of its own so deep links and the help mark
* land on the right one. Old tab names (general, account, locations, activity…) still resolve. */
import { Suspense, useEffect, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useSnap } from "@/lib/client";
import { PageHead } from "@/components/ui";
import dynamic from "next/dynamic";
import { CSV_TEMPLATES } from "@/lib/csv";
import { SECTIONS, SettingsStyles, resolveSection, type SectionId } from "@/components/settings/common";
import FacilitySection from "@/components/settings/FacilitySection";
import IssuingRules from "@/components/settings/IssuingRules";
import CatalogueSection from "@/components/settings/CatalogueSection";
import PlacesSection from "@/components/settings/PlacesSection";
import PeopleSignIn from "@/components/settings/PeopleSignIn";
import DataAudit from "@/components/settings/DataAudit";
// Plan pulls in Stripe; load it only when the Plan section is open.
const PlanTab = dynamic(() => import("@/components/PlanTab"), { ssr: false });
// useSearchParams needs a Suspense boundary for static rendering.
export default function SettingsPage() {
return <Suspense fallback={null}><SettingsInner /></Suspense>;
}
function SettingsInner() {
const { s, isAdmin } = useSnap();
const sp = useSearchParams();
const planShown = isAdmin && !!s.plan?.live && !s.demo;
// #hash forms from old links (/app/settings#account).
const [hash, setHash] = useState("");
useEffect(() => {
const read = () => setHash(window.location.hash.replace("#", ""));
read();
window.addEventListener("hashchange", read);
return () => window.removeEventListener("hashchange", read);
}, []);
const tabParam = sp.get("tab");
const importParam = sp.get("import") || "";
const importKind = CSV_TEMPLATES[importParam] ? importParam : undefined;
const resolved = resolveSection(tabParam || hash || (importKind ? "data" : ""));
let section: SectionId = resolved.section;
if (section === "plan" && !planShown) section = "facility";
const sections = SECTIONS.filter((x) => x.id !== "plan" || planShown);
return (
<section>
<SettingsStyles />
<PageHead title="Settings" />
<div className="tc-set">
<nav aria-label="Settings sections" className="tc-set-nav">
{sections.map((x) => (
<Link key={x.id} href={`/app/settings?tab=${x.id}`} scroll={false} aria-current={x.id === section ? "page" : undefined}>{x.label}</Link>
))}
</nav>
<div className="tc-set-body" key={section}>
{section === "facility" && <FacilitySection />}
{section === "issuing" && <IssuingRules />}
{section === "catalogue" && <CatalogueSection />}
{section === "places" && <PlacesSection />}
{section === "people" && <PeopleSignIn />}
{section === "data" && <DataAudit importKind={importKind} scrollAudit={resolved.audit} />}
{section === "plan" && planShown && <PlanTab />}
</div>
</div>
</section>
);
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { Suspense } from "react";
import StaffRecord from "@/components/people/Record";
// The record's tab and edit mode live in the address; useSearchParams needs a Suspense boundary.
export default function StaffProfile() {
return <Suspense fallback={null}><StaffRecord /></Suspense>;
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { Suspense } from "react";
import Register from "@/components/people/Register";
// Register reads its filters from the address; useSearchParams needs a Suspense boundary.
export default function StaffPage() {
return <Suspense fallback={null}><Register /></Suspense>;
}
+414
View File
@@ -0,0 +1,414 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Fragment, useEffect, useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { PageHead, Empty, ErrorLine, Field, Notice } from "@/components/ui";
import { AdjustDialog, DuplicateItemDialog, GROUPS_HINT, GroupsPicker, ScanVariantsDialog } from "@/components/dialogs";
import { Icon, MoreMenu, Panel, QtyStepper, Tag } from "@/components/portal";
import { StockStyles } from "@/components/stock/StockStyles";
import { wholeMoney } from "@/components/stock/url";
import { bcBound, countsAsIssued, fmtDate, forecastFor, forecastLabel, fyStart, garmentGroups, genderLabel, issueCost, itemOrderHistory, key, lastCountMap, locTree, money, onOrderMap, onhand, plOf, reorderAt, staffName, statusTag, supplierCodeOf, touched } from "@/lib/compute";
export default function GarmentPage() {
const { id } = useParams<{ id: string }>();
const { s, isAdmin, mutate } = useSnap();
const { L, byId, staffById } = useDerived();
const it = s.catalog.find((x) => x.id === id);
const [edit, setEdit] = useState(false);
const [f, setF] = useState({ item: "", sku: "", supplier: "", cost: "", gender: "Unisex", groups: [] as string[], notes: "" });
const [newSize, setNewSize] = useState("");
const [err, setErr] = useState("");
const [msg, setMsg] = useState("");
const [adjust, setAdjust] = useState<{ itemId: string; si: number } | null>(null);
const [scanSizes, setScanSizes] = useState(false);
const [dup, setDup] = useState(false);
// What is typed into each size's barcode box, until it is saved: a label code is as often read out
// and typed as it is scanned.
const [codes, setCodes] = useState<Record<number, string>>({});
const [rowErr, setRowErr] = useState<{ si: number; msg: string } | null>(null);
const [flash, setFlash] = useState<number | null>(null);
// Arriving from "Create and scan sizes" (?scan=1) opens the scanner straight away; ?size=<si>
// (a scanned garment, a size cell) brings that size's row into view and marks it for two seconds.
useEffect(() => {
const sp = new URLSearchParams(window.location.search);
if (sp.get("scan") === "1") {
if (isAdmin && it) setScanSizes(true);
sp.delete("scan");
window.history.replaceState(null, "", window.location.pathname + (sp.toString() ? "?" + sp.toString() : ""));
}
const size = sp.get("size");
if (size === null || !it) return;
const si = parseInt(size, 10);
if (!(si >= 0 && si < it.sizes.length)) return;
const t0 = window.setTimeout(() => {
const el = document.getElementById(`size-row-${si}`);
if (el) { el.scrollIntoView({ block: "center" }); el.focus({ preventScroll: true }); }
setFlash(si);
}, 50);
const t1 = window.setTimeout(() => setFlash(null), 2050);
return () => { window.clearTimeout(t0); window.clearTimeout(t1); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, !!it]);
const d = useMemo(() => {
if (!it) return null;
const oo = onOrderMap(s, byId).byKey;
const lastCount = lastCountMap(s);
const sizes = it.sizes.map((sz, si) => { const k = key(it.id, si); return { si, size: String(sz), key: k, oh: onhand(s, L, k), ro: reorderAt(s, k), touched: touched(s, L, k), barcode: bcBound(s, it, si), pl: plOf(s, k), onOrd: oo[k] || 0, last: lastCount[k] || "" }; });
const tot = sizes.reduce((t, v) => t + v.oh, 0);
const value = sizes.reduce((t, v) => t + Math.max(0, v.oh), 0) * it.cost;
const fy = fyStart(s.today);
const fyList = s.issues.filter((i) => i.itemId === it.id && i.date >= fy && countsAsIssued(i));
const fyIssued = fyList.reduce((t, i) => t + i.qty, 0);
const fySpend = fyList.reduce((t, i) => t + i.qty * issueCost(i, byId), 0);
const onOrder = sizes.reduce((t, v) => t + v.onOrd, 0);
const hist: { date: string; kind: string; cls: string; desc: string }[] = [];
for (const i of s.issues) if (i.itemId === it.id) {
hist.push({ date: i.date, kind: i.direct ? "Collected" : "Issued", cls: "tag tag-neutral", desc: `${it.sizes[i.si]} ×${i.qty}${staffName(staffById[i.staffId], "—")}` });
if (i.returned) hist.push({ date: i.returned.date, kind: i.returned.cond.replace("Returned - ", "Returned "), cls: "tag tag-outline", desc: `${it.sizes[i.si]} ×${i.qty}${staffName(staffById[i.staffId], "—")}` });
}
for (const o of s.orders) for (const rc of o.receipts) for (const l of rc.lines) if (l.itemId === it.id) hist.push({ date: rc.date, kind: "Received", cls: "tag tag-accent", desc: `${l.size} ×${l.qty}${o.code}${l.dest === "shelf" ? " → shelf" : " → staff pickup"}` });
// A counted correction isn't a write-off: it's the shelf disagreeing with the ledger, either way.
for (const m of s.moves) if (m.itemId === it.id) hist.push({ date: m.date, kind: m.reason === "Counted correction" ? "Counted" : m.qty < 0 ? "Write-off" : "Added", cls: "tag tag-outline", desc: `${it.sizes[m.si] ?? ""} ${m.reason === "Counted correction" ? (m.qty < 0 ? "" : "+") + Math.abs(m.qty) : "×" + Math.abs(m.qty)}${m.reason ? " — " + m.reason : ""}` });
hist.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
return { sizes, tot, value, fyIssued, fySpend, onOrder, hist: hist.slice(0, 25) };
}, [it, s, L, byId, staffById]);
if (!it || !d) {
return (
<section>
<StockStyles />
<PageHead title="Garment not found" />
<nav aria-label="Breadcrumb" className="tc-stk-crumb"><Icon name="chevronLeft" size={16} /><Link href="/app/stock">Stock</Link></nav>
<Empty>That garment isn&apos;t in the catalogue.</Empty>
</section>
);
}
const tagged = garmentGroups(it.groups);
const startEdit = () => { setF({ item: it.item, sku: it.sku, supplier: it.supplier, cost: String(it.cost), gender: it.gender, groups: tagged, notes: it.notes }); setErr(""); setEdit(true); };
const invalid = !f.item.trim() || !(parseFloat(f.cost) >= 0) || f.cost === "";
async function save() {
if (invalid) return;
const r = await mutate("catalog.update", { id: it!.id, item: f.item, sku: f.sku, supplier: f.supplier, cost: parseFloat(f.cost), gender: f.gender, groups: f.groups, notes: f.notes });
if (!r.ok) { setErr(r.error); return; }
setEdit(false);
}
async function act(op: string, payload: unknown) { setErr(""); setRowErr(null); setMsg(""); const r = await mutate(op, payload); if (!r.ok) setErr(r.error); }
const sizeInvalid = !newSize.trim() || it.sizes.map(String).includes(newSize.trim());
// Adding a size does not mint a barcode: most sizes arrive with the supplier's own number.
async function addSize() {
if (sizeInvalid) return;
setErr("");
const r = await mutate("catalog.update", { id: it!.id, addSize: newSize.trim() });
if (!r.ok) { setErr(r.error); return; }
setNewSize("");
}
// A size row's own refusal belongs on that row.
function rowFail(si: number, m: string) { setErr(""); setRowErr({ si, msg: m }); }
async function saveCode(si: number, size: string, bound: string, force = false) {
const code = (codes[si] ?? bound).trim();
if (code === bound) { setCodes((c) => ({ ...c, [si]: bound })); return; }
// Clearing the box is how a wrong code comes off.
if (!code) { if (confirm(`Unbind ${bound} from size ${size}?`)) await unbindCode(si, bound); return; }
setErr(""); setRowErr(null);
const r = await mutate("barcode.bind", { code, itemId: it!.id, si, force });
if (!r.ok) {
// "Already on another garment" is the one refusal force may clear.
if (!force && r.error.includes("re-bind to move it") && confirm(`${r.error}\n\nMove ${code} onto ${it!.item} · size ${size}?`)) { await saveCode(si, size, bound, true); return; }
rowFail(si, r.error); return;
}
setCodes((c) => ({ ...c, [si]: code }));
}
async function unbindCode(si: number, code: string) {
setErr(""); setRowErr(null);
const r = await mutate("barcode.unbind", { code });
if (!r.ok) { rowFail(si, r.error); return; }
setCodes((c) => ({ ...c, [si]: "" }));
}
// Our own number for sizes that arrived without one. Only fills gaps; the server has the last word.
async function generateAll() {
const missing = d!.sizes.filter((v) => !v.barcode).length;
if (missing && !confirm(`Generate a barcode for the ${missing} size${missing === 1 ? "" : "s"} on ${it!.item} with none? Sizes with a suppliers code keep it.`)) return;
setErr(""); setRowErr(null); setMsg("");
const r = await mutate<{ made: { si: number; size: string; code: string }[]; count: number }>("barcode.generate", { itemId: it!.id });
if (!r.ok) { setErr(r.error); return; }
setCodes({});
setMsg(`Generated ${r.result.count} barcode${r.result.count === 1 ? "" : "s"} — size${r.result.count === 1 ? "" : "s"} ${r.result.made.map((m) => m.size).join(", ")}.`);
}
async function generateOne(si: number, size: string) {
setErr(""); setRowErr(null); setMsg("");
const r = await mutate<{ made: { si: number; size: string; code: string }[]; count: number }>("barcode.generate", { itemId: it!.id, si });
if (!r.ok) { rowFail(si, r.error); return; }
setCodes((c) => { const n = { ...c }; delete n[si]; return n; });
setMsg(`Size ${size} now carries ${r.result.made.map((m) => m.code).join(", ")}.`);
}
async function removeSize(si: number, size: string) {
if (!confirm(`Remove size ${size} from ${it!.item}? Its reorder level and barcode go with it.`)) return;
setErr(""); setRowErr(null);
const r = await mutate("catalog.removeSize", { id: it!.id, si });
if (!r.ok) { rowFail(si, r.error); return; }
// Every size above the removed one shifts down a place.
setCodes({});
}
// One label per garment on hand across the sizes that carry a code, so the count goes on the menu
// item and into the question before the print dialog opens.
const labelled = d.sizes.filter((v) => v.barcode).length;
const labels = d.sizes.reduce((t, v) => t + (v.barcode ? Math.max(0, v.oh) : 0), 0);
function printLabels() {
if (labels && !confirm(`Print ${labels} label${labels === 1 ? "" : "s"} for ${it!.item}? One for every garment on hand, across the ${labelled} size${labelled === 1 ? "" : "s"} carrying a barcode.`)) return;
window.open(`/print/labels?item=${encodeURIComponent(it!.id)}`, "_blank", "noopener");
}
const locOpts = locTree(s).map(({ loc, depth }) => ({ id: loc.id, name: " ".repeat(depth * 2) + loc.name }));
const sp = s.supplierDir.find((x) => x.name === it.supplier);
const hist = itemOrderHistory(s, it.id);
const prices = s.costs.filter((c) => c.itemId === it.id).sort((a, b) => b.at.localeCompare(a.at));
const colCount = 10;
return (
<section>
<StockStyles />
<PageHead
title={it.item}
below={
<div className="tc-stk-tags">
{tagged.length ? tagged.map((g) => <Tag key={g}>{g}</Tag>) : <Tag>All groups</Tag>}
{it.gender !== "Unisex" && <Tag>{genderLabel(it.gender)}</Tag>}
<Tag>{it.supplier || "No supplier"}</Tag>
{it.archived && <Tag tone="accent">Discontinued</Tag>}
</div>
}
>
<div className="tc-stk-headfig">
<span className="tc-stk-mono">{d.tot}</span>
on hand · <span className="tc-mono">{wholeMoney(d.value)}</span>
</div>
{isAdmin && (!edit ? (
<>
<button type="button" className="btn btn-primary" onClick={startEdit}>Edit garment</button>
<button type="button" className="btn btn-onink" onClick={() => setScanSizes(true)}>Scan sizes</button>
<MoreMenu tone="ink" items={[
{ label: `Print labels (${labels})`, onSelect: printLabels },
{ label: "Generate barcodes", onSelect: generateAll },
{ label: "Duplicate", onSelect: () => setDup(true) },
it.archived
? { label: "Reinstate", onSelect: () => act("catalog.update", { id: it.id, archived: false }) }
: { label: "Discontinue", danger: true, onSelect: () => act("catalog.update", { id: it.id, archived: true }) },
]} />
</>
) : (
<>
<button type="button" className="btn btn-primary" onClick={save} disabled={invalid}>Save changes</button>
<button type="button" className="btn btn-onink" onClick={() => setEdit(false)}>Cancel</button>
</>
))}
</PageHead>
<nav aria-label="Breadcrumb" className="tc-stk-crumb">
<Icon name="chevronLeft" size={16} /><Link href="/app/stock">Stock</Link><span aria-hidden="true">/</span><span style={{ fontWeight: 600, color: "var(--color-text)" }} aria-current="page">{it.item}</span>
</nav>
<ErrorLine msg={err} />
<Notice msg={msg} />
<div className="tc-stk-grid" style={{ marginTop: 8 }}>
<div className="tc-stk-col">
{edit && (
<Panel title="Edit details">
<div className="tc-stk-pad tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<Field label="Item name" style={{ gridColumn: "1 / -1" }} error={!f.item.trim() ? "Needed — the garment has to have a name." : undefined}>{(c) => <input {...c} className="input" value={f.item} onChange={(e) => setF({ ...f, item: e.target.value })} />}</Field>
<Field label="SKU / style code">{(c) => <input {...c} className="input" value={f.sku} onChange={(e) => setF({ ...f, sku: e.target.value })} />}</Field>
<Field label="Unit cost ($)" error={f.cost !== "" && !(parseFloat(f.cost) >= 0) ? "Give a number, or 0." : undefined}>{(c) => <input {...c} className="input" inputMode="decimal" value={f.cost} onChange={(e) => setF({ ...f, cost: e.target.value.replace(/[^0-9.]/g, "") })} />}</Field>
<Field label="Supplier">{(c) => <><input {...c} className="input" list="tc-suppliers" value={f.supplier} onChange={(e) => setF({ ...f, supplier: e.target.value })} /><datalist id="tc-suppliers">{s.settings.suppliers.map((x) => <option key={x} value={x} />)}</datalist></>}</Field>
<Field label="Gender">{(c) => <select {...c} className="input" value={f.gender} onChange={(e) => setF({ ...f, gender: e.target.value })}><option value="Unisex">Unisex</option><option value="Male">Men&apos;s</option><option value="Female">Women&apos;s</option></select>}</Field>
<GroupsPicker style={{ gridColumn: "1 / -1" }} value={f.groups} onChange={(groups) => setF({ ...f, groups })} groups={s.settings.staffGroups} hint={GROUPS_HINT} />
<Field label="Notes" style={{ gridColumn: "1 / -1" }}>{(c) => <textarea {...c} className="input" rows={2} value={f.notes} onChange={(e) => setF({ ...f, notes: e.target.value })} placeholder="Fit notes, replacement style, supplier quirks…" />}</Field>
</div>
</Panel>
)}
<Panel title="Sizes" aside={<span className="tc-mono">{labelled} of {it.sizes.length} carry a barcode</span>}
foot={isAdmin ? (
<div className="tc-stk-row" style={{ alignItems: "flex-end", width: "100%" }}>
<Field label="Add a size" hint="Starts with no barcode." style={{ flex: 1, minWidth: 160 }} error={newSize.trim() && it.sizes.map(String).includes(newSize.trim()) ? "That size is already on this garment." : undefined}>{(c) => <input {...c} className="input" value={newSize} onChange={(e) => setNewSize(e.target.value)} placeholder="e.g. 6XL or 127" onKeyDown={(e) => { if (e.key === "Enter") addSize(); }} />}</Field>
<button type="button" className="btn btn-secondary" onClick={addSize} disabled={sizeInvalid}>Add size</button>
</div>
) : undefined}>
<div className="table-wrap">
<table className="tc-table tc-stk-table" style={{ minWidth: isAdmin ? 1040 : 860 }}>
<thead>
<tr>
<th>Size</th><th>Barcode</th><th>Status</th><th className="num">On hand</th><th className="num">Pre-loved</th><th className="num">On order</th><th>Last counted</th><th>Location</th><th className="num">Reorder at</th><th><span className="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
{d.sizes.map((v) => {
const status = v.oh <= 0 ? (v.touched ? "out" : "none") : v.oh <= v.ro ? "reorder" : "ok";
const draft = codes[v.si] ?? v.barcode;
const dirty = draft.trim() !== v.barcode;
return (
<Fragment key={v.si}>
<tr id={`size-row-${v.si}`} tabIndex={-1} className={flash === v.si ? "tc-stk-flash" : undefined}>
<td className="tc-mono" style={{ fontWeight: 600 }}>{v.size}</td>
<td>
{isAdmin ? (
<span className="tc-stk-row" style={{ gap: 4, flexWrap: "nowrap" }}>
<input className="input tc-stk-tight tc-mono" style={{ width: 150 }} value={draft} maxLength={64} inputMode="numeric" placeholder="Not bound"
aria-label={`Barcode for size ${v.size}`} title="Type or scan the label code. Clear the box to unbind."
onChange={(e) => setCodes({ ...codes, [v.si]: e.target.value })}
onKeyDown={(e) => { if (e.key === "Enter") saveCode(v.si, v.size, v.barcode); }} />
{dirty
? <button type="button" className="btn btn-secondary tc-stk-tight" aria-label={`Save the barcode for size ${v.size}`} onClick={() => saveCode(v.si, v.size, v.barcode)}>Save</button>
: v.barcode
? <button type="button" className="btn btn-ghost btn-icon" title="Unbind this barcode" aria-label={`Unbind barcode ${v.barcode} from size ${v.size}`} onClick={() => { if (confirm(`Unbind ${v.barcode} from size ${v.size}?`)) unbindCode(v.si, v.barcode); }}>×</button>
: <button type="button" className="btn btn-ghost tc-stk-tight" aria-label={`Generate a barcode for size ${v.size}`} onClick={() => generateOne(v.si, v.size)}>Generate</button>}
</span>
) : (
<span className="tc-mono" style={{ fontSize: 12, color: v.barcode ? undefined : "var(--color-neutral-600)" }}>{v.barcode || "Not bound"}</span>
)}
</td>
<td>{status === "out" ? <Tag tone="accent">Out</Tag> : status === "reorder" ? <Tag tone="low">Reorder</Tag> : status === "ok" ? <Tag tone="quiet">OK</Tag> : <Tag tone="quiet"></Tag>}</td>
<td className="num" style={{ fontWeight: 600, color: status === "out" || status === "reorder" ? "var(--color-accent-700)" : undefined }}>{v.oh}</td>
<td className="num">{v.pl > 0 ? v.pl : ""}</td>
<td className="num">{v.onOrd > 0 ? v.onOrd : ""}</td>
<td className="tc-mono" style={{ fontSize: 12 }}>{v.last ? fmtDate(v.last) : "never"}</td>
<td>
<select className="input tc-stk-tight" value={s.placed[v.key] || ""} aria-label={`Where size ${v.size} lives`}
onChange={(e) => act("location.place", { itemId: it.id, si: v.si, locationId: e.target.value })}
disabled={s.locations.length === 0} title={s.locations.length === 0 ? "No locations yet" : undefined}>
<option value="">{s.locations.length === 0 ? "—" : "Unplaced"}</option>
{locOpts.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
</td>
<td className="num">
{isAdmin
? <QtyStepper size="sm" label={`reorder level for size ${v.size}`} value={v.ro} onChange={(n) => act("stock.reorder", { itemId: it.id, si: v.si, reorder: Math.max(0, n) })} />
: v.ro}
</td>
<td style={{ whiteSpace: "nowrap", textAlign: "right" }}>
<button type="button" className="btn btn-ghost tc-stk-tight" aria-label={`Adjust the quantity of size ${v.size}`} onClick={() => setAdjust({ itemId: it.id, si: v.si })}>Adjust</button>
{isAdmin && <> <button type="button" className="btn btn-ghost tc-stk-tight" aria-label={`Remove size ${v.size} from this garment`} onClick={() => removeSize(v.si, v.size)}>Remove</button></>}
</td>
</tr>
{rowErr?.si === v.si && <tr><td colSpan={colCount} style={{ borderTop: 0, paddingTop: 0 }}><ErrorLine msg={rowErr.msg} /></td></tr>}
</Fragment>
);
})}
</tbody>
</table>
</div>
</Panel>
<Panel title="Ordering" aside={<>{it.supplier || "no supplier"}{sp?.lead ? <> · <span className="tc-mono">{sp.lead}</span>-day lead</> : null}</>}>
<div className="table-wrap">
<table className="tc-table" style={{ minWidth: 620 }}>
<thead>
<tr><th>Size</th><th>Supplier code</th><th title="Weekly issues over 13 weeks (26 if none) × (lead time + 2 weeks)">Usage · suggested reorder</th><th className="num">Reorder at</th></tr>
</thead>
<tbody>
{d.sizes.map((v) => {
const fc = forecastFor(s, L, byId, v.key);
const code = supplierCodeOf(s, v.key);
return (
<tr key={"ord" + v.si}>
<td className="tc-mono" style={{ fontWeight: 600 }}>{v.size}</td>
<td>{isAdmin
? <input key={code} className="input tc-stk-tight tc-mono" style={{ width: 180 }} defaultValue={code} placeholder="e.g. NW-10422-M" maxLength={60} aria-label={`Supplier code for size ${v.size}`}
onBlur={(e) => { if (e.target.value.trim() !== code) act("stock.supplierCode", { itemId: it.id, si: v.si, code: e.target.value.trim() }); }}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} />
: <span className="tc-mono">{code || "—"}</span>}</td>
<td>
<span className="tc-stk-row" style={{ gap: 8 }}>
<span>{fc.suggestedReorder !== null ? <><b>Suggested <span className="tc-mono">{fc.suggestedReorder}</span></b> · {forecastLabel(fc)}</> : forecastLabel(fc)}</span>
{fc.runsOutBeforeDelivery && <Tag tone="low" title="At the current rate the shelf runs out before a delivery placed today would arrive">runs out before delivery</Tag>}
{isAdmin && fc.suggestedReorder !== null && fc.suggestedReorder !== v.ro && <button type="button" className="btn btn-ghost tc-stk-tight" aria-label={`Set the reorder level for size ${v.size} to ${fc.suggestedReorder}`} onClick={() => act("stock.reorder", { itemId: it.id, si: v.si, reorder: fc.suggestedReorder })}>Use</button>}
</span>
</td>
<td className="num" style={{ fontWeight: 600 }}>{v.ro}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Panel>
<Panel title="Orders" aside={<span className="tc-mono">{hist.length} line{hist.length === 1 ? "" : "s"}</span>}>
{hist.length === 0 ? <div className="tc-stk-pad"><Empty pad={2}>Never ordered.</Empty></div> : (
<div className="table-wrap">
<table className="tc-table" style={{ minWidth: 820 }}>
<thead>
<tr><th>Date</th><th>Order</th><th>Supplier</th><th>Size</th><th className="num">Qty</th><th className="num">Unit then</th><th>Supplier ref</th><th>Invoice</th><th>Status</th></tr>
</thead>
<tbody>
{hist.map((h, i) => (
<tr key={i}>
<td className="tc-mono">{fmtDate(h.date)}</td>
<td className="tc-mono"><Link href={`/app/orders/${h.orderId}`}>{h.code}</Link></td>
<td>{h.supplier || "—"}</td>
<td className="tc-mono">{h.size}</td>
<td className="num" style={{ fontWeight: 600 }}>{h.qty}</td>
<td className="num">{h.unit ? money(h.unit) : "—"}</td>
<td className="tc-mono">{h.ref || "—"}</td>
<td className="tc-mono">{h.invoice || "—"}</td>
<td><span className={statusTag(h.status)}>{h.status}</span></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Panel>
<Panel title="Price history" aside={<>now <span className="tc-mono">{money(it.cost)}</span></>}>
{prices.length === 0 ? <div className="tc-stk-pad"><Empty pad={2}>No price changes recorded.</Empty></div> : (
<div>
{prices.map((c) => (
<div key={c.id} className="tc-stk-kv">
<span><span className="tc-mono">{fmtDate(c.at.slice(0, 10))}</span>{c.byName ? ` · ${c.byName}` : ""}</span>
<span className="tc-mono">{c.previous !== null ? `${money(c.previous)}` : ""}{money(c.cost)}</span>
</div>
))}
</div>
)}
</Panel>
{it.notes && !edit && (
<Panel title="Notes">
<div className="tc-stk-pad" style={{ fontSize: 13, lineHeight: 1.6, whiteSpace: "pre-wrap" }}>{it.notes}</div>
</Panel>
)}
</div>
<div className="tc-stk-col">
<Panel title="This financial year">
<div>
{([["Issued this FY", `${d.fyIssued}`], ["FY spend (at issue price)", money(d.fySpend)], ["On open orders", `${d.onOrder}`], ["Unit cost", money(it.cost)], ["Sizes carried", String(it.sizes.length)]] as const).map(([k, v]) => (
<div key={k} className="tc-stk-kv"><span>{k}</span><span className="tc-mono" style={{ fontWeight: 600 }}>{v}</span></div>
))}
</div>
</Panel>
<Panel title="Recent movement" aside={d.hist.length > 0 ? "newest first" : undefined}>
{d.hist.length === 0 ? <div className="tc-stk-pad"><Empty pad={2}>No movement recorded yet.</Empty></div> : (
<div>
{d.hist.map((h, i) => (
<div key={i} className="tc-stk-kv" style={{ justifyContent: "flex-start" }}>
<span className="tc-mono tc-stk-meta" style={{ flex: "none", width: 84 }}>{fmtDate(h.date)}</span>
<span className={h.cls} style={{ flex: "none" }}>{h.kind}</span>
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{h.desc}</span>
</div>
))}
</div>
)}
</Panel>
</div>
</div>
{adjust && <AdjustDialog init={adjust} onClose={() => setAdjust(null)} />}
{/* Drafts go when the scanner closes: it binds codes to these same sizes. */}
{scanSizes && <ScanVariantsDialog item={it} onClose={() => { setScanSizes(false); setCodes({}); }} />}
{dup && <DuplicateItemDialog item={it} onClose={() => setDup(false)} />}
</section>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useSnap } from "@/lib/client";
import { PageHead } from "@/components/ui";
import { Seg } from "@/components/portal";
import { ItemDialog, ScanAddDialog } from "@/components/dialogs";
import OnHand, { asStockFilter } from "@/components/stock/OnHand";
import CountTab from "@/components/stock/CountTab";
import Locations from "@/components/stock/Locations";
import { StockStyles } from "@/components/stock/StockStyles";
import { ALL_GROUPS } from "@/lib/compute";
const TABS = ["onhand", "count", "locations"] as const;
type StockTab = (typeof TABS)[number];
const TAB_LABELS: Record<StockTab, string> = { onhand: "On hand", count: "Count", locations: "Locations" };
const TAB_HREFS: Record<StockTab, string> = { onhand: "/app/stock?tab=onhand", count: "/app/stock?tab=count", locations: "/app/stock?tab=locations" };
function StockScreen() {
const router = useRouter();
const sp = useSearchParams();
const { isAdmin } = useSnap();
const raw = sp.get("tab");
const tab: StockTab = raw === "count" || raw === "locations" ? raw : "onhand";
const [newItem, setNewItem] = useState(false);
const [scanAdd, setScanAdd] = useState(false);
return (
<section>
<StockStyles />
<PageHead title="Stock">
<Seg tone="ink" label="Stock view" opts={TABS} value={tab} onChange={() => {}} labels={TAB_LABELS} hrefs={TAB_HREFS} />
{isAdmin && <button type="button" className="btn btn-onink" onClick={() => setScanAdd(true)}>Scan to add</button>}
{isAdmin && <button type="button" className="btn btn-primary" onClick={() => setNewItem(true)}>Add garment</button>}
</PageHead>
{tab === "onhand" && (
<OnHand init={{ filter: asStockFilter(sp.get("filter")), q: sp.get("q") || "", group: sp.get("group") || ALL_GROUPS, supplier: sp.get("supplier") || "" }} />
)}
{tab === "count" && <CountTab initLocation={sp.get("location") || ""} />}
{tab === "locations" && <Locations />}
{newItem && <ItemDialog onClose={() => setNewItem(false)} onSaved={(id) => router.push(`/app/stock/${encodeURIComponent(id)}`)} />}
{scanAdd && <ScanAddDialog onClose={() => setScanAdd(false)} />}
</section>
);
}
// useSearchParams wants a Suspense boundary above it.
export default function StockPage() {
return <Suspense fallback={null}><StockScreen /></Suspense>;
}
+15
View File
@@ -0,0 +1,15 @@
import { redirect } from "next/navigation";
/* The stock take moved into Stock as its Count tab. next.config.ts redirects this path too; the
stub keeps an old bookmark working (with its query) if that redirect is ever missing. */
export default async function StocktakeRedirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
const sp = await searchParams;
const q = new URLSearchParams();
for (const [k, v] of Object.entries(sp)) {
if (k === "tab") continue;
if (Array.isArray(v)) v.forEach((x) => q.append(k, x));
else if (v !== undefined) q.append(k, v);
}
const rest = q.toString();
redirect(`/app/stock?tab=count${rest ? "&" + rest : ""}`);
}
+41
View File
@@ -0,0 +1,41 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { redirect } from "next/navigation";
import { currentUser } from "@/lib/session";
import { switches } from "@/lib/switches";
import AuthForm from "@/components/AuthForm";
import { COMMUNITY } from "@/lib/edition";
export const dynamic = "force-dynamic";
/* Its own identity, and out of the index.
*
* Without this the page inherited the root layout's title and its canonical, so the sign-in screen
* announced itself as the marketing homepage in the browser tab, in a bookmark and in a shared
* link — and told search engines it *was* the homepage, which is the one thing a canonical must
* never say about a different page. Nothing here is any use in a search result either: it is a door
* for people who already have an account. */
export const metadata: Metadata = {
title: "Log in",
alternates: { canonical: "/auth" },
robots: { index: false, follow: false },
};
export default async function AuthPage({ searchParams }: { searchParams: Promise<{ mode?: string; next?: string; error?: string }> }) {
const sp = await searchParams;
const user = await currentUser();
// Only ever redirect within the app (never to an absolute or protocol-relative URL).
// The phone app lives under /m; anything else off-site is refused so ?next= can't be an open redirect.
const next = sp.next && (sp.next.startsWith("/app") || sp.next === "/m" || sp.next.startsWith("/m/")) && !sp.next.startsWith("//") ? sp.next : "/app";
if (user) redirect(next);
const { signupsOpen, plansLive } = await switches();
return (
// The whole page is the sign-in form, so it is the main landmark. There is nothing in front of
// it to bypass, which is why there is no skip link here.
<main>
<Suspense>
<AuthForm initialMode={sp.mode === "signup" && signupsOpen ? "signup" : "login"} next={next} signupsOpen={signupsOpen} plansLive={plansLive} sso={!COMMUNITY} ssoError={typeof sp.error === "string" && sp.error.startsWith("sso_") ? sp.error : ""} />
</Suspense>
</main>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
/* The route-level error boundary. Something threw while rendering a page; the shell survives, so
this keeps the site's own chrome and offers the two things that actually help — try again, and
a way to tell someone.
The error's message is deliberately not printed: it is written by the server, can carry internal
detail, and means nothing to a linen services manager. The digest is shown because it is the one
string that lets a report be matched to a log line. */
import Link from "next/link";
import { HAS_SITE } from "@/lib/links";
import { useEffect } from "react";
import { reportError } from "@/lib/errors";
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => { reportError(error, "route"); }, [error]);
return (
<div style={{ fontFamily: "var(--font-body)", color: "var(--color-text)", background: "var(--color-bg)", minHeight: "100vh", display: "flex", alignItems: "center" }}>
<div style={{ maxWidth: 640, margin: "0 auto", padding: "clamp(32px,6vw,64px) clamp(20px,5vw,40px)" }}>
<div style={{ fontSize: 12, letterSpacing: "0.15em", textTransform: "uppercase", fontWeight: 800, color: "var(--color-accent-700)" }}>
Something went wrong
</div>
<h1 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(30px,5vw,52px)", lineHeight: 1.0, letterSpacing: "-0.03em", margin: "16px 0 0" }}>
This page didn&rsquo;t load.
</h1>
<div style={{ width: 60, height: 4, background: "var(--color-accent)", margin: "22px 0 0" }} />
<p style={{ fontSize: 16.5, lineHeight: 1.7, color: "var(--color-neutral-800)", margin: "22px 0 0" }}>
The fault is ours, not yours, and nothing you were doing has been lost ThreadCount only
changes a record when you commit it. Try again, and if it keeps happening, tell us and
we&rsquo;ll go and look.
</p>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", marginTop: 28 }}>
<button onClick={reset} className="btn btn-primary" style={{ cursor: "pointer", font: "inherit" }}>Try again</button>
<Link href="/" className="btn">Back to the start</Link>
{HAS_SITE && <Link href="/support" className="btn">Tell us</Link>}
</div>
{error.digest ? (
<p style={{ fontSize: 12.5, color: "var(--color-neutral-700)", marginTop: 26, fontFamily: "monospace" }}>
Reference {error.digest} quote this and we can find it in the log.
</p>
) : null}
</div>
</div>
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+70
View File
@@ -0,0 +1,70 @@
"use client";
/* The last resort: the root layout itself failed, so this replaces <html> entirely. Nothing from
the app is available here — not the font, not globals.css, not the site components — so every
style is inline and the type falls back to a system stack rather than Archivo. Keeping it
self-contained is the point: this page has to render when everything else has not. */
import { useEffect } from "react";
import { safeLocation } from "@/lib/errors";
import { report } from "@/lib/glitchtip";
const INK = "#201e1d";
const PAPER = "#f3f2f2";
const ACCENT = "#ec3013";
const SANS = "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
/* Reported straight to the reporter, not through lib/errors' window seam.
*
* That seam is published by components/ErrorReporting, which is mounted inside the root layout —
* the very layout that has just failed. When this boundary renders, that effect has by definition
* not run, so window.__tcReporter is undefined and reportError falls through to a no-op in
* production: the one boundary that means "everything is broken" was the only one sending
* nothing. Importing the reporter directly costs a couple of kilobytes in the bundle that renders
* this page and removes the dependency on a component that cannot have mounted. */
useEffect(() => { report({ error, where: "global", url: safeLocation() }); }, [error]);
return (
<html lang="en">
<body style={{ margin: 0, background: PAPER, color: INK, fontFamily: SANS }}>
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center" }}>
<div style={{ maxWidth: 620, margin: "0 auto", padding: "48px 24px" }}>
<div style={{ display: "inline-flex", alignItems: "center", gap: 10, marginBottom: 30 }}>
<span style={{ width: 15, height: 15, background: ACCENT, display: "inline-block" }} />
<span style={{ fontWeight: 800, fontSize: 19, letterSpacing: "-0.01em" }}>ThreadCount</span>
</div>
<div style={{ fontSize: 12, letterSpacing: "0.15em", textTransform: "uppercase", fontWeight: 800, color: ACCENT }}>
Service error
</div>
<h1 style={{ fontWeight: 800, fontSize: "clamp(28px,5vw,46px)", lineHeight: 1.05, letterSpacing: "-0.03em", margin: "14px 0 0" }}>
ThreadCount is having a moment.
</h1>
<div style={{ width: 60, height: 4, background: ACCENT, margin: "22px 0 0" }} />
<p style={{ fontSize: 16.5, lineHeight: 1.7, margin: "22px 0 0" }}>
Something failed before the page could be built. Your records are untouched this is
the website falling over, not the linen room. Try again in a moment.
</p>
<div style={{ marginTop: 28, display: "flex", gap: 12, flexWrap: "wrap" }}>
<button
onClick={reset}
style={{ font: "inherit", fontWeight: 800, letterSpacing: "0.02em", textTransform: "uppercase", fontSize: 13, background: ACCENT, color: "#fff", border: 0, padding: "14px 22px", cursor: "pointer" }}
>
Try again
</button>
<a
href="/"
style={{ font: "inherit", fontWeight: 800, letterSpacing: "0.02em", textTransform: "uppercase", fontSize: 13, background: "transparent", color: INK, border: `2px solid ${INK}`, padding: "12px 20px", textDecoration: "none" }}
>
Back to the start
</a>
</div>
{error.digest ? (
<p style={{ fontSize: 12.5, color: "#6b6764", marginTop: 26, fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}>
Reference {error.digest}
</p>
) : null}
</div>
</div>
</body>
</html>
);
}
+1430
View File
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
import type { Metadata } from "next";
import { Archivo, IBM_Plex_Mono } from "next/font/google";
import "./globals.css";
import ErrorReporting from "@/components/ErrorReporting";
const archivo = Archivo({
variable: "--font-archivo",
subsets: ["latin"],
weight: ["400", "600", "700", "800", "900"],
display: "swap",
});
// The marketing site sets product codes, size runs, times and cost centres in a monospace face,
// the way a coordinator reads them off a slip. Only the public pages use it (globals.css scopes
// --font-mono to .tcm-site); the app and the two phone apps never see it.
const plexMono = IBM_Plex_Mono({
variable: "--font-plex-mono",
subsets: ["latin"],
weight: ["400", "600"],
display: "swap",
});
export const viewport = { width: "device-width", initialScale: 1, viewportFit: "cover" as const };
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://threadcount.tech";
const DESC = "Uniform stock management for hospitals, aged care, clinics and community care. What's on the shelf, who took it and what it cost the ward or clinic — orders and stocktakes in one place.";
export const metadata: Metadata = {
metadataBase: new URL(SITE),
title: { default: "ThreadCount — Uniform management for hospitals, aged care and clinics", template: "%s — ThreadCount" },
description: DESC,
applicationName: "ThreadCount",
alternates: { canonical: "/" },
// Without these a link pasted into Slack, Teams or an email renders as a bare URL.
openGraph: {
type: "website", siteName: "ThreadCount", url: SITE, locale: "en_AU",
title: "ThreadCount — Uniform management for hospitals, aged care and clinics",
description: DESC,
images: [{ url: "/og.png", width: 1200, height: 630, alt: "ThreadCount — every garment out the door, accounted for." }],
},
twitter: { card: "summary_large_image", title: "ThreadCount — Uniform management for hospitals, aged care and clinics", description: DESC, images: ["/og.png"] },
robots: { index: true, follow: true },
formatDetection: { telephone: false },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${archivo.variable} ${plexMono.variable}`}>
<body>
{children}
{/* Global handlers for the client crashes that never reach a React boundary. */}
<ErrorReporting />
</body>
</html>
);
}
+513
View File
@@ -0,0 +1,513 @@
"use client";
/* The product card, on the phone.
*
* Two halves, because they are two different jobs. The top is the garment's description, which is
* typed once and rarely changed. The bottom is per size — par level, barcode, what's on hand —
* which is what someone standing at a shelf actually came here to adjust.
*
* The size index — a position in that list — is what every issue, order line and barcode points at,
* so the order of the list is never offered for editing: shuffling it would silently repoint years
* of records. One size can be taken off, though, and the server does the deciding: it shifts every
* later size down across the ten tables that store a position, in one transaction, and refuses
* outright when the size being removed has anything recorded against it. So this screen offers the
* removal on every size and shows whatever comes back. */
import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { bcBound, formatInZone, key as vkey, label, onhand, reorderAt, splitKey, type CostRec, type Item } from "@/lib/compute";
import { isNative } from "@/lib/nativescan";
import MScan from "@/components/MScan";
import {
ACCENT, GROUND, INK, ON_DARK, MBar, MBody, MError, MField, MNote, MRule, MSection, MTop, inputStyle,
} from "@/components/m";
/* The two ways to get a code onto a size, side by side. Scanning stays the primary act, ink-filled:
it is the fastest when the camera cooperates. Typing sits beside it rather than behind it — a
label in your hand beats a camera that won't focus, and it is the only way to reach a code the
scanner keeps putting on the wrong garment. */
const codeBtn: React.CSSProperties = {
flex: 1, minHeight: 48, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800,
fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer",
};
/* Undoing rather than doing: the quieter kind of action on a size row. No border, so it reads as a
link; 44px tall, so it is still a target you can hit with gloves on. */
const quietAction: React.CSSProperties = {
display: "flex", alignItems: "center", width: "100%", minHeight: 44, background: "none", border: 0,
padding: 0, font: "inherit", fontSize: 12.5, fontWeight: 700, color: "var(--color-neutral-700)",
textAlign: "left", cursor: "pointer",
};
/* What a freshly minted number is, and what it still isn't.
*
* The code exists in ThreadCount the moment it is made, but the garment on the rack carries nothing
* until somebody prints it and sticks it on — so the confirmation carries the print with it rather
* than leaving it to be found at the foot of a fifteen-size screen. */
function MMade({ made, inApp, labels, inset, onPrint }: {
made: { size: string; code: string }[]; inApp: boolean; labels: number; inset?: boolean; onPrint: () => void;
}) {
if (!made.length) return null;
return (
<div style={{ margin: inset ? "10px 0 0" : 16, padding: 16, background: INK, color: GROUND, fontSize: 13.5, lineHeight: 1.6 }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 16, letterSpacing: "-0.01em" }}>
{made.length === 1 ? `Size ${made[0].size} has a barcode now` : `${made.length} sizes have a barcode now`}
</div>
<div style={{ marginTop: 8, color: ON_DARK, fontSize: 12.5, fontVariantNumeric: "tabular-nums" }}>
{made.map((m) => <div key={m.code}>{m.size} · {m.code}</div>)}
</div>
<div style={{ marginTop: 10 }}>
{inApp
? "Nothing is on the garments yet. Printing is a desktop job — the app cant open a label sheet — so open ThreadCount on the desktop and print this garments labels from there."
: labels
? "Nothing is on the garments yet. Print the labels and stick one on each."
: "Nothing is on the garments yet, and nothing in a labelled size is on the shelf to stick one on. Count some in and the labels will print, one for each garment."}
</div>
{!inApp && labels > 0 && (
<button onClick={onPrint}
style={{ width: "100%", minHeight: 48, marginTop: 12, border: "2px solid " + GROUND, background: GROUND, color: INK, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer" }}>
Print labels
</button>
)}
</div>
);
}
export default function MProductCard() {
const { id } = useParams<{ id: string }>();
const { s, isAdmin, mutate, busy } = useSnap();
const { L, byId } = useDerived();
const router = useRouter();
const it = s.catalog.find((x: Item) => x.id === id);
const [err, setErr] = useState("");
const [editing, setEditing] = useState(false);
const [f, setF] = useState(() => ({
item: it?.item ?? "", type: it?.type ?? "", group: it?.group ?? "All",
supplier: it?.supplier ?? "", sku: it?.sku ?? "", cost: it ? String(it.cost) : "", notes: it?.notes ?? "",
}));
const [newSize, setNewSize] = useState("");
const [scanFor, setScanFor] = useState<number | null>(null);
const [typeFor, setTypeFor] = useState<number | null>(null);
const [typed, setTyped] = useState("");
/* Which run is in flight, so only the button that was pressed says so: `busy` is true for every
mutation on the screen, and fifteen rows all reading "Generating…" because somebody nudged a
par level is a lie. -1 is the whole-garment run. */
const [genFor, setGenFor] = useState<number | null>(null);
const [made, setMade] = useState<{ si: number; size: string; code: string }[]>([]);
/* The Android shell cannot print: its WebView opens no second window, so the label sheet would
replace the app, and window.print() doesn't exist there. Same reading as the reprint screen,
taken after mount — the server render doesn't know which shell it is being sent to. */
const [inApp, setInApp] = useState(false);
useEffect(() => { setInApp(isNative()); }, []);
const groups = useMemo(() => {
const set = new Set<string>(["All"]);
for (const st of s.staff) if (st.group) set.add(st.group);
for (const i of s.catalog) if (i.group) set.add(i.group);
return [...set].sort();
}, [s.staff, s.catalog]);
if (!it) {
return (
<>
<MTop title="Garment" back />
<MRule />
<MBody><MNote tone="warn">That garment isn&rsquo;t in the catalogue any more.</MNote></MBody>
</>
);
}
const name = label(byId[it.id] ?? it);
// Newest first; the snapshot already caps how many it carries.
const costs: CostRec[] = s.costs.filter((c) => c.itemId === it.id);
const readOnly = !isAdmin;
// How many sizes a whole-garment run would cover, and how much paper a print run would produce —
// one label per garment on the shelf. Both read through the same bcBound and onhand the rows
// below use, so the two numbers on this screen can never disagree with each other.
const unlabelled = it.sizes.filter((_: string, si: number) => !bcBound(s, it, si)).length;
const labels = it.sizes.reduce((n: number, _: string, si: number) =>
n + (bcBound(s, it, si) ? Math.max(0, onhand(s, L, vkey(it.id, si))) : 0), 0);
/* Fill the boxes from the garment as it stands right now, not as it stood when the screen was
* opened. The card refreshes underneath without remounting, so a coordinator can be looking at a
* unit cost somebody else raised on the desktop minutes ago while this form still holds the old
* one — and saving would quietly put the old price back and file a "Down from $24.00" cost change
* in the wrong person's name. Every issue costed after that would use the stale figure. */
function startEdit() {
setErr("");
setF({
item: it!.item, type: it!.type, group: it!.group,
supplier: it!.supplier, sku: it!.sku, cost: String(it!.cost), notes: it!.notes,
});
setEditing(true);
}
async function saveDetails() {
setErr("");
if (!f.item.trim()) { setErr("The garment needs a name."); return; }
const c = f.cost.trim() ? Number(f.cost) : 0;
if (!(c >= 0) || Number.isNaN(c)) { setErr("Cost has to be a number."); return; }
const r = await mutate("catalog.update", {
id: it!.id, item: f.item.trim(), type: f.type.trim(), group: f.group,
supplier: f.supplier.trim(), sku: f.sku.trim(), cost: c, notes: f.notes,
});
if (!r.ok) { setErr(r.error); return; }
setEditing(false);
}
async function addSize() {
const sz = newSize.trim();
if (!sz) return;
setErr("");
const r = await mutate("catalog.update", { id: it!.id, addSize: sz });
if (!r.ok) { setErr(r.error); return; }
setNewSize("");
}
async function setPar(si: number, next: number) {
const r = await mutate("stock.reorder", { itemId: it!.id, si, reorder: Math.max(0, next) });
if (!r.ok) setErr(r.error);
}
/** Where a code already sits, named the way a person would name it, or "" if this snapshot has
* never seen it. */
function boundElsewhere(code: string): string {
const at = s.barcodes[code];
if (!at) return "";
const { itemId, si } = splitKey(at);
const other = byId[itemId];
return other ? `${label(other)} · size ${other.sizes[si] ?? si}` : "";
}
/* Binding, including the refusal that used to be a dead end.
*
* A code scanned onto the wrong garment can only be put right by moving it, and barcode.bind
* won't move one unless it is told to — so when that is why it refused, offer the move rather
* than printing the message and stopping there. The snapshot is asked where the code sits so the
* question can name the garment it would come off; the server's own sentence, which names it too,
* is the fallback for a code somebody else bound since this page loaded. The other refusal — a
* generated 93XXXXXXX code, which stands for a garment rather than sitting on a label — is
* refused with or without force, matches neither test, and is shown as it came. */
async function bind(si: number, raw: string) {
const code = raw.trim();
if (!code) return;
setErr(""); setMade([]);
const r = await mutate("barcode.bind", { code, itemId: it!.id, si });
if (r.ok) { setTypeFor(null); setTyped(""); return; }
const at = boundElsewhere(code);
if (!at && !/is already on/.test(r.error)) { setErr(r.error); return; }
const ask = at
? `${code} is on ${at}. Take it off there and put it on ${name} · size ${it!.sizes[si]}?`
: `${r.error}\n\nMove it onto ${name} · size ${it!.sizes[si]}?`;
if (!confirm(ask)) { setErr(r.error); return; }
const moved = await mutate("barcode.bind", { code, itemId: it!.id, si, force: true });
if (!moved.ok) { setErr(moved.error); return; }
setTypeFor(null); setTyped("");
}
async function unbind(si: number, code: string) {
if (!confirm(`Unbind ${code} from ${name} · size ${it!.sizes[si]}? Scanning that label won't find this size any more.`)) return;
setErr(""); setMade([]);
const r = await mutate("barcode.unbind", { code });
if (!r.ok) setErr(r.error);
}
/* The server decides whether a size can go — it is the one that can count what has been recorded
* against this exact position — so the offer is made on every size and the refusal is shown when
* one comes back. Removing shifts the sizes after it down a place, so anything this screen is
* holding open against a position has to let go of it. */
async function removeSize(si: number) {
if (!confirm(`Remove size ${it!.sizes[si]} from ${name}? Its par level and any barcode on it go with it.`)) return;
setErr("");
const r = await mutate("catalog.removeSize", { id: it!.id, si });
if (!r.ok) { setErr(r.error); return; }
setScanFor(null); setTypeFor(null); setTyped(""); setMade([]);
}
/* Printing our own barcode for stock that arrived without one — the cafe shirts came with nothing
* printed on any of fifteen sizes, and a garment nobody can scan is invisible to a count and
* cannot be issued by scanning. The number is a real EAN-13 from the range GS1 keeps for exactly
* this, so every scanner in the building already reads it.
*
* The server decides what is missing: it fills only the gaps, leaves a size carrying a supplier's
* code alone, and refuses outright when there is nothing to do. So the offer is made and whatever
* comes back is shown, rather than the button being hidden on this screen's guess about a
* snapshot that may be a few seconds old. */
async function generate(si?: number) {
setErr(""); setMade([]);
setGenFor(si ?? -1);
const r = await mutate<{ made: { si: number; size: string; code: string }[]; count: number }>(
"barcode.generate", si === undefined ? { itemId: it!.id } : { itemId: it!.id, si },
);
setGenFor(null);
if (!r.ok) { setErr(r.error); return; }
setMade(r.result.made);
}
/* Not destructive, but it does put numbers on garments — and on a rack of fifteen sizes it is a
good deal more than the person pressing it can see at once. So it says how many first. */
async function generateAll() {
const ask = `Generate a barcode for ${unlabelled} size${unlabelled === 1 ? "" : "s"} on ${name}? Sizes that already carry a supplier's code keep theirs, and nothing is on a garment until the labels are printed.`;
if (unlabelled > 0 && !confirm(ask)) return;
await generate();
}
/* A whole garment's labels: one per garment on hand, every size that carries a code. A second
window rather than this one, because leaving the screen would lose the size list somebody is
halfway through labelling — and inside the app there is no second window to open, which is why
every path to here is closed off when `inApp`. */
function printLabels() {
window.open(`/print/labels?item=${encodeURIComponent(it!.id)}`, "_blank", "noopener");
}
async function archive() {
const r = await mutate("catalog.update", { id: it!.id, archived: !it!.archived });
if (!r.ok) { setErr(r.error); return; }
if (!it!.archived) router.replace("/m/catalogue");
}
return (
<>
<MTop title={it.archived ? "Archived" : "Garment"} right={`${it.sizes.length} size${it.sizes.length === 1 ? "" : "s"}`} back />
<MRule />
<MBody>
<MError msg={err} onDismiss={() => setErr("")} />
{it.archived && <MNote tone="warn">This garment is archived. It stays on old records but can&rsquo;t be issued.</MNote>}
{/* ---- the description ---- */}
{!editing ? (
<>
<div style={{ padding: "18px 16px", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em", lineHeight: 1.1 }}>{name}</div>
<div style={{ fontSize: 13.5, color: "var(--color-neutral-700)", marginTop: 8, lineHeight: 1.6 }}>
{[it.type, it.group === "All" ? "Anyone" : it.group, it.supplier, it.sku].filter(Boolean).join(" · ") || "No details yet"}
<br />
{it.cost ? `$${it.cost.toFixed(2)} each` : "No unit cost set"}
</div>
{it.notes && <div style={{ fontSize: 13, color: "var(--color-neutral-800)", marginTop: 10, lineHeight: 1.6 }}>{it.notes}</div>}
</div>
{!readOnly && (
<div style={{ padding: 16, borderBottom: "2px solid " + INK }}>
<button onClick={startEdit}
style={{ width: "100%", minHeight: 52, border: "2px solid " + INK, background: "transparent", color: INK, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>
Edit details
</button>
</div>
)}
</>
) : (
<>
<MField label="Garment">
<input value={f.item} onChange={(e) => setF({ ...f, item: e.target.value })} autoCapitalize="words" style={inputStyle} />
</MField>
<MField label="Type">
<input value={f.type} onChange={(e) => setF({ ...f, type: e.target.value })} style={inputStyle} />
</MField>
<MField label="Who wears it">
<select value={f.group} onChange={(e) => setF({ ...f, group: e.target.value })} style={{ ...inputStyle, appearance: "none" }}>
{groups.map((g) => <option key={g} value={g}>{g === "All" ? "Anyone" : g}</option>)}
</select>
</MField>
<MField label="Supplier">
<input value={f.supplier} onChange={(e) => setF({ ...f, supplier: e.target.value })} style={inputStyle} />
</MField>
<MField label="Supplier code">
<input value={f.sku} onChange={(e) => setF({ ...f, sku: e.target.value })} autoCapitalize="characters" autoCorrect="off" spellCheck={false} style={inputStyle} />
</MField>
<MField label="Unit cost">
<input value={f.cost} onChange={(e) => setF({ ...f, cost: e.target.value })} inputMode="decimal" style={inputStyle} />
</MField>
<MField label="Notes">
<input value={f.notes} onChange={(e) => setF({ ...f, notes: e.target.value })} placeholder="Optional" style={inputStyle} />
</MField>
<div style={{ padding: 16, display: "grid", gap: 10, borderBottom: "2px solid " + INK }}>
<button onClick={saveDetails} disabled={busy}
style={{ width: "100%", minHeight: 52, border: "2px solid " + ACCENT, background: ACCENT, color: "#fff", font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>
{busy ? "Saving…" : "Save details"}
</button>
<button onClick={() => { setEditing(false); setErr(""); setF({ item: it.item, type: it.type, group: it.group, supplier: it.supplier, sku: it.sku, cost: String(it.cost), notes: it.notes }); }}
style={{ width: "100%", minHeight: 48, border: 0, background: "none", color: "var(--color-neutral-700)", font: "inherit", fontSize: 13.5, fontWeight: 700, cursor: "pointer" }}>
Cancel
</button>
</div>
</>
)}
{/* ---- per size ---- */}
<MSection label="Sizes" right="On hand · par" />
{it.sizes.map((sz: string, si: number) => {
const k = vkey(it.id, si);
const oh = onhand(s, L, k);
const par = reorderAt(s, k);
// The bound supplier code only. bcFor()'s generated 93XXXXXXX fallback is printed on no
// garment, so showing it made every size look labelled and hid the ones that need one.
const code = bcBound(s, it, si);
return (
<div key={si} style={{ padding: "14px 16px", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, minWidth: 54 }}>{sz}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: "var(--color-neutral-700)" }}>
<b style={{ color: oh <= par ? "var(--color-accent-700)" : INK, fontSize: 15 }}>{oh}</b> on hand
</div>
<div style={{ fontSize: 12, color: code ? "var(--color-neutral-700)" : "var(--color-neutral-600)", marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{code || "No barcode bound"}
</div>
</div>
{!readOnly && (
<div style={{ display: "flex", alignItems: "center", gap: 0 }}>
<button onClick={() => setPar(si, par - 1)} aria-label={`Lower par for ${sz}`}
style={{ width: 44, height: 44, border: "2px solid " + INK, background: "transparent", color: INK, fontSize: 20, fontWeight: 800, cursor: "pointer" }}></button>
<div style={{ minWidth: 44, height: 44, border: "2px solid " + INK, borderLeft: 0, borderRight: 0, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 16 }}>{par}</div>
<button onClick={() => setPar(si, par + 1)} aria-label={`Raise par for ${sz}`}
style={{ width: 44, height: 44, border: "2px solid " + INK, background: "transparent", color: INK, fontSize: 20, fontWeight: 800, cursor: "pointer" }}>+</button>
</div>
)}
</div>
{!readOnly && (
<div style={{ marginTop: 10 }}>
{typeFor === si ? (
<div style={{ display: "grid", gap: 8 }}>
{/* A numeric keypad, because a supplier code is thirteen digits and that is
the keyboard you can hit accurately while holding the garment. It is only
a hint to the keyboard: whatever arrives is taken as typed, so the
alphanumeric codes some labels carry go through on a keyboard that offers
letters, and pasting is unaffected either way. */}
<input value={typed} onChange={(e) => setTyped(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") bind(si, typed); }}
placeholder="Barcode on the label" inputMode="numeric" autoFocus
autoCapitalize="characters" autoCorrect="off" spellCheck={false}
aria-label={`Barcode for size ${sz}`} style={inputStyle} />
<div style={{ display: "flex", gap: 8 }}>
<button onClick={() => bind(si, typed)} disabled={busy || !typed.trim()}
style={{ ...codeBtn, border: "2px solid " + ACCENT, background: ACCENT, color: "#fff", opacity: typed.trim() ? 1 : 0.4 }}>
{busy ? "Binding…" : "Bind"}
</button>
<button onClick={() => { setTypeFor(null); setTyped(""); }}
style={{ ...codeBtn, border: "2px solid var(--color-neutral-400)", background: "transparent", color: "var(--color-neutral-700)" }}>
Cancel
</button>
</div>
</div>
) : (
<>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={() => setScanFor(si)} aria-label={`Scan a barcode for size ${sz}`}
style={{ ...codeBtn, border: "2px solid " + INK, background: INK, color: GROUND }}>
{code ? "Scan a new one" : "Scan"}
</button>
<button onClick={() => { setErr(""); setTyped(""); setTypeFor(si); }} aria-label={`Type a barcode for size ${sz}`}
style={{ ...codeBtn, border: "2px solid " + INK, background: "transparent", color: INK }}>
Type it in
</button>
</div>
{/* Stock that turned up with nothing printed on it has no label to scan and no
number to type, so the third way is to make one. Offered only where nothing
is bound: wherever the supplier printed a code, that code is the one the
delivery note will use next time and it stays. */}
{!code && (
<button onClick={() => generate(si)} disabled={busy} aria-label={`Generate a barcode for size ${sz}`}
style={{ ...codeBtn, width: "100%", marginTop: 8, border: "2px solid " + INK, background: "transparent", color: INK, opacity: busy ? 0.5 : 1 }}>
{genFor === si ? "Generating…" : "Generate a barcode"}
</button>
)}
</>
)}
{code && <button onClick={() => unbind(si, code)} style={quietAction}>Unbind {code}</button>}
<button onClick={() => removeSize(si)} style={quietAction}>Remove size {sz}</button>
{made.length === 1 && made[0].si === si && <MMade made={made} inApp={inApp} labels={labels} inset onPrint={printLabels} />}
</div>
)}
</div>
);
})}
{!readOnly && (
<div style={{ padding: 16, borderBottom: "2px solid " + INK, display: "flex", gap: 10 }}>
<input value={newSize} onChange={(e) => setNewSize(e.target.value)} placeholder="Add a size"
autoCapitalize="characters" autoCorrect="off" spellCheck={false}
style={{ ...inputStyle, flex: 1 }} />
<button onClick={addSize} disabled={busy || !newSize.trim()}
style={{ minWidth: 96, border: "2px solid " + INK, background: INK, color: GROUND, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer", opacity: newSize.trim() ? 1 : 0.4 }}>
Add
</button>
</div>
)}
{!readOnly && (
<>
<MSection label="Barcodes" right={unlabelled ? `${unlabelled} without` : "All labelled"} />
{made.length > 1 && <MMade made={made} inApp={inApp} labels={labels} onPrint={printLabels} />}
<div style={{ padding: 16, display: "grid", gap: 10, borderBottom: "2px solid " + INK }}>
<button onClick={generateAll} disabled={busy}
style={{ width: "100%", minHeight: 52, border: "2px solid " + INK, background: "transparent", color: INK, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer", opacity: busy ? 0.5 : 1 }}>
{genFor === -1 ? "Generating…" : "Generate for every unlabelled size"}
</button>
<button onClick={printLabels} disabled={inApp || labels === 0}
style={{ width: "100%", minHeight: 52, border: "2px solid " + ACCENT, background: ACCENT, color: "#fff", font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase", cursor: inApp || labels === 0 ? "not-allowed" : "pointer", opacity: inApp || labels === 0 ? 0.5 : 1 }}>
{inApp ? "Print on the desktop" : "Print labels"}
</button>
<div style={{ fontSize: 12.5, color: "var(--color-neutral-700)", lineHeight: 1.6 }}>
{inApp
? "Printing is a desktop job — the app cant open a label sheet. Open ThreadCount on the desktop and print this garments labels from there."
: labels
? `One label for every garment on hand in a size that carries a code — ${labels} at the moment, six to an A4 sheet.`
: "Nothing on the shelf carries a code yet, so there is nothing to print."}
</div>
</div>
</>
)}
{!readOnly && (
<div style={{ padding: 16 }}>
<button onClick={archive}
style={{ background: "none", border: 0, padding: 0, font: "inherit", fontSize: 13, fontWeight: 700, color: it.archived ? "var(--color-accent-700)" : "var(--color-neutral-700)", cursor: "pointer" }}>
{it.archived ? "Put this garment back in the catalogue" : "Archive this garment"}
</button>
</div>
)}
{/* What we used to pay. CatalogItem.cost is a single field, so without this a price rise
silently erased the old figure — and "what did these cost last year" is a question
finance asks every year. */}
{costs.length > 0 && (
<>
<MSection label="What it has cost" right="Changed by" />
{costs.map((c) => (
<div key={c.id} style={{ display: "flex", alignItems: "baseline", gap: 12, padding: "12px 16px", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 17, minWidth: 78, fontVariantNumeric: "tabular-nums" }}>
${c.cost.toFixed(2)}
</div>
<div style={{ flex: 1, minWidth: 0, fontSize: 12.5, color: "var(--color-neutral-700)" }}>
{c.previous === null
? "Opening price"
: `${c.previous > c.cost ? "Down" : "Up"} from $${c.previous.toFixed(2)}`}
{" · "}
{/* The facility's zone, not the device's. A price change stamped at 09:00 in
Perth is a different calendar day on a phone left set to Sydney, and this
page is server-rendered first: with no zone pinned the server and the browser
formatted the same instant differently and React threw the markup away. */}
{formatInZone(c.at, s.tz)}
</div>
<div style={{ fontSize: 12, color: "var(--color-neutral-600)", whiteSpace: "nowrap" }}>{c.byName}</div>
</div>
))}
</>
)}
{readOnly && <MNote>Only an admin can change the catalogue.</MNote>}
</MBody>
{!readOnly && !it.archived && <MBar label="Done" glyph="check" tone="ink" onClick={() => router.push("/m/catalogue")} />}
{scanFor !== null && (
<MScan
title={`Barcode for ${it.sizes[scanFor]}`}
onClose={() => setScanFor(null)}
onHit={(raw) => { const si = scanFor; setScanFor(null); if (si !== null) bind(si, raw); }}
/>
)}
</>
);
}
+155
View File
@@ -0,0 +1,155 @@
"use client";
/* Create a garment from the counter.
*
* The desktop form asks for everything at once, which is right when you are importing a range.
* Here the only genuinely required things are a name and at least one size — the server enforces
* exactly that — so everything else can be filled in later from the product card. A coordinator
* with a new garment in one hand and a phone in the other should be able to make it exist in about
* twenty seconds and scan it in.
*
* Sizes are entered as a run rather than one at a time, because that is how they arrive: a garment
* comes in S-M-L-XL, not as four separate decisions. */
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useSnap } from "@/lib/client";
import type { Item } from "@/lib/compute";
import { INK, MBar, MBody, MError, MField, MNote, MRule, MTop, inputStyle } from "@/components/m";
const COMMON_RUNS: [string, string][] = [
["XS S M L XL", "XS · S · M · L · XL"],
["S M L XL 2XL", "S · M · L · XL · 2XL"],
["8 10 12 14 16 18", "8 18"],
["77R 82R 87R 92R", "77R 92R"],
];
export default function MCatalogueNew() {
const { s, isAdmin, mutate, busy } = useSnap();
const router = useRouter();
const [item, setItem] = useState("");
const [type, setType] = useState("");
const [group, setGroup] = useState("All");
const [supplier, setSupplier] = useState("");
const [sku, setSku] = useState("");
const [cost, setCost] = useState("");
const [sizeText, setSizeText] = useState("");
const [err, setErr] = useState("");
// Split on commas, slashes or whitespace so a run can be typed however it comes to hand.
const sizes = useMemo(
() => sizeText.split(/[,/\s]+/).map((x) => x.trim()).filter(Boolean),
[sizeText],
);
const dupSize = useMemo(() => sizes.length !== new Set(sizes).size, [sizes]);
/* The facility's configured groups are the vocabulary; the register and the catalogue only ever
* add to it.
*
* This used to be built from the groups already in USE, which made it impossible to put a garment
* on a role nothing had used yet — the first Kitchen shirt could never be added from the counter,
* because "Kitchen" only appeared in the list once a Kitchen garment existed. On a facility whose
* register has not been imported yet it collapsed to "Anyone" and whatever one or two groups the
* first few items happened to carry. The desktop dialog has always read settings.staffGroups;
* this is the same field and now has the same source. The in-use ones are still folded in so a
* group that predates the configured list, or arrived on a CSV import, does not vanish. */
const groups = useMemo(() => {
const set = new Set<string>(["All", ...s.settings.staffGroups]);
for (const st of s.staff) if (st.group) set.add(st.group);
for (const i of s.catalog) if (i.group) set.add(i.group);
return [...set].sort();
}, [s.staff, s.catalog, s.settings.staffGroups]);
const types = useMemo(() => [...new Set(s.catalog.map((i: Item) => i.type).filter(Boolean))].sort(), [s.catalog]);
const suppliers = useMemo(() => s.supplierDir.map((x) => x.name).sort(), [s.supplierDir]);
if (!isAdmin) {
return (
<>
<MTop title="New garment" back />
<MRule />
<MBody><MNote tone="warn">Only an admin can add to the catalogue.</MNote></MBody>
</>
);
}
async function save() {
setErr("");
if (!item.trim()) { setErr("Give the garment a name."); return; }
if (!sizes.length) { setErr("Add at least one size."); return; }
if (dupSize) { setErr("The same size is listed twice."); return; }
const c = cost.trim() ? Number(cost) : 0;
if (!(c >= 0) || Number.isNaN(c)) { setErr("Cost has to be a number, or left blank."); return; }
const r = await mutate<{ id: string }>("catalog.add", {
item: item.trim(), type: type.trim(), group, supplier: supplier.trim(),
sku: sku.trim(), cost: c, sizes,
});
if (!r.ok) { setErr(r.error); return; }
// Straight to the product card: the next thing anyone does is bind a barcode or set par.
router.replace(`/m/catalogue/${r.result.id}`);
}
return (
<>
<MTop title="New garment" back />
<MRule />
<MBody>
<MError msg={err} onDismiss={() => setErr("")} />
<MField label="Garment">
<input value={item} onChange={(e) => { setItem(e.target.value); setErr(""); }}
placeholder="Scrub top" autoCapitalize="words" enterKeyHint="next" style={inputStyle} />
</MField>
<MField label="Sizes">
<input value={sizeText} onChange={(e) => { setSizeText(e.target.value); setErr(""); }}
placeholder="S M L XL" autoCapitalize="characters" autoCorrect="off" spellCheck={false} style={inputStyle} />
</MField>
<div style={{ padding: "0 16px 14px", display: "flex", flexWrap: "wrap", gap: 8 }}>
{COMMON_RUNS.map(([run, pretty]) => (
<button key={run} onClick={() => { setSizeText(run); setErr(""); }}
style={{ border: "2px solid " + INK, background: "transparent", color: INK, padding: "8px 12px", fontSize: 12.5, fontWeight: 700, cursor: "pointer" }}>
{pretty}
</button>
))}
</div>
{sizes.length > 0 && (
<div style={{ padding: "0 16px 14px", fontSize: 13, color: dupSize ? "var(--color-accent-700)" : "var(--color-neutral-700)", fontWeight: dupSize ? 700 : 400 }}>
{dupSize ? "The same size is listed twice." : `${sizes.length} size${sizes.length === 1 ? "" : "s"}: ${sizes.join(" · ")}`}
</div>
)}
<MField label="Type">
<input list="tc-types" value={type} onChange={(e) => setType(e.target.value)} placeholder="Scrub top" style={inputStyle} />
<datalist id="tc-types">{types.map((t) => <option key={t} value={t} />)}</datalist>
</MField>
<MField label="Who wears it">
<select value={group} onChange={(e) => setGroup(e.target.value)} style={{ ...inputStyle, appearance: "none" }}>
{groups.map((g) => <option key={g} value={g}>{g === "All" ? "Anyone" : g}</option>)}
</select>
</MField>
<MField label="Supplier">
<input list="tc-suppliers" value={supplier} onChange={(e) => setSupplier(e.target.value)} placeholder="Optional" style={inputStyle} />
<datalist id="tc-suppliers">{suppliers.map((x) => <option key={x} value={x} />)}</datalist>
</MField>
<MField label="Supplier code">
<input value={sku} onChange={(e) => setSku(e.target.value)} placeholder="Optional" autoCapitalize="characters" autoCorrect="off" spellCheck={false} style={inputStyle} />
</MField>
<MField label="Unit cost">
<input value={cost} onChange={(e) => { setCost(e.target.value); setErr(""); }}
inputMode="decimal" placeholder="0.00" style={inputStyle} />
</MField>
<MNote>
Barcodes, par levels and opening stock are set on the product card once this exists it
is quicker to scan a garment in than to type its code.
</MNote>
</MBody>
<MBar label={busy ? "Saving…" : "Create garment"} onClick={save} disabled={busy} />
</>
);
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
/* The catalogue on the phone.
*
* This used to be one of the rows under "On the desktop" — listed, greyed out, untappable, with
* the note that adding garments is a sit-down job. It genuinely is, for a bulk import of two
* hundred lines. It is not for the thing that actually happens in a linen room: a new garment
* turns up at the counter and needs to exist before it can be scanned in.
*
* So this is the whole catalogue, not just what's on the shelf — /m/stock deliberately shows only
* variants with history, which means a garment created five minutes ago wouldn't appear there. */
import Link from "next/link";
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { label, type Item } from "@/lib/compute";
import { INK, IconPlus, MBody, MEmpty, MNav, MNote, MRow, MRule, MSection, MTop, inputStyle } from "@/components/m";
export default function MCatalogue() {
const { s, isAdmin } = useSnap();
const { byId } = useDerived();
const [q, setQ] = useState("");
const [showArchived, setShowArchived] = useState(false);
const rows = useMemo(() => {
const needle = q.trim().toLowerCase();
return s.catalog
.filter((i: Item) => (showArchived ? i.archived : !i.archived))
.map((i: Item) => ({
...i,
name: label(byId[i.id] ?? i),
sub: [i.sizes.length ? `${i.sizes.length} size${i.sizes.length === 1 ? "" : "s"}` : "No sizes yet", i.supplier || "No supplier", i.sku].filter(Boolean).join(" · "),
}))
.filter((i) => !needle || `${i.name} ${i.sku} ${i.supplier} ${i.type} ${i.group}`.toLowerCase().includes(needle))
.sort((a, b) => a.name.localeCompare(b.name));
}, [s.catalog, byId, q, showArchived]);
const archivedCount = s.catalog.filter((i: Item) => i.archived).length;
const addLink: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 12, minHeight: 64, padding: "0 20px",
border: "2px solid " + INK, color: INK, textDecoration: "none",
fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase",
};
return (
<>
<MTop title="Catalogue" right={`${rows.length} item${rows.length === 1 ? "" : "s"}`} back />
<MRule />
<MBody>
<div style={{ padding: 16, borderBottom: "2px solid " + INK }}>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Garment, code, supplier" aria-label="Filter the catalogue" style={inputStyle} />
</div>
{isAdmin && (
<div style={{ padding: 16, borderBottom: "1px solid var(--color-divider)" }}>
<Link href="/m/catalogue/new" style={addLink}>
<IconPlus /><span style={{ flex: 1 }}>New garment</span>
</Link>
</div>
)}
<MSection label={showArchived ? "Archived" : "Garments"} right={isAdmin ? "Tap to edit" : undefined} />
{rows.length === 0
? <MEmpty
title={q ? "Nothing matches" : showArchived ? "Nothing archived" : "No garments yet"}
sub={q ? "Try a shorter search." : isAdmin ? "Add the first one and it can be scanned in straight away." : "An admin sets the catalogue up."} />
: rows.slice(0, 300).map((i) => (
<MRow
key={i.id}
href={isAdmin ? `/m/catalogue/${i.id}` : undefined}
mark={i.archived ? "mute" : "ink"}
title={i.name}
sub={i.sub}
right={<span style={{ fontSize: 12, color: "var(--color-neutral-600)" }}>{i.cost ? `$${i.cost.toFixed(2)}` : ""}</span>}
/>
))}
{archivedCount > 0 && (
<div style={{ padding: 16 }}>
<button
onClick={() => setShowArchived(!showArchived)}
style={{ background: "none", border: 0, padding: 0, font: "inherit", fontSize: 13, fontWeight: 700, color: "var(--color-accent-700)", cursor: "pointer" }}>
{showArchived ? "Back to the current catalogue" : `Show ${archivedCount} archived`}
</button>
</div>
)}
{!isAdmin && <MNote>Only an admin can change the catalogue. You can still see what exists.</MNote>}
</MBody>
<MNav />
</>
);
}
+220
View File
@@ -0,0 +1,220 @@
"use client";
/* Counting — the screen the app exists for. Scan a garment, the active line goes up by one.
Expected quantities stay visible throughout: this is a sighted count, not a blind one.
The tally lives in localStorage, so backgrounding the app mid-shelf loses nothing. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { bcBound, locMap, locSubtree, locUnder, onhand, touched, UNPLACED, variantName } from "@/lib/compute";
import MScan from "@/components/MScan";
import { scanReject } from "@/lib/feedback";
import { track } from "@/lib/analytics";
import { useKeepAwake } from "@/lib/wakelock";
import { INK, MAction, MBody, MEmpty, MError, MFigures, MInkLink, MPanel, MRow, MRule, MSection, MSplit, MTop, ON_DARK, inputStyle } from "@/components/m";
import { readCount, writeCount } from "@/lib/opencount";
export default function MCounting() {
const { s } = useSnap();
const { L, byId, variants } = useDerived();
const router = useRouter();
const locationId = String(useParams().id || "");
const locs = useMemo(() => locMap(s), [s]);
const loc = locs[locationId];
const locName = locationId === UNPLACED ? "Not on a shelf" : loc?.name || "Location";
// The lines on this shelf, in catalogue order.
//
// Being placed on the shelf is enough to be countable: a size placed from the desktop but never
// stocked has no history at all, and filtering it out meant the six of them you have just found
// on the shelf could not be counted in from the count that found them. The unplaced bucket still
// needs the history test, or it would be the whole catalogue.
//
// The variance screen repeats this test verbatim, and the two have to keep listing the same
// lines: anything countable here but missing there is counted on the phone and then dropped at
// commit, with the tally cleared behind it and nothing said.
const lines = useMemo(() => {
const sub = locationId === UNPLACED ? null : locSubtree(s, locationId);
return variants
// A bound barcode counts as much as stock history does. Somebody stood at the counter with
// the garment in one hand and scanned its label onto that size — that is a stronger statement
// that the size physically exists than a stock figure, which on a room being set up is
// precisely what nobody has yet. Without this the first count after building a catalogue can
// reach nothing at all: every size is unplaced and untouched, so the list is empty and every
// scan is refused as belonging somewhere else.
.filter((v) => (sub ? sub.has(s.placed[v.key] || "") : !s.placed[v.key] && (touched(s, L, v.key) || !!bcBound(s, v.item, v.si))))
.map((v) => ({ ...v, expected: onhand(s, L, v.key), code: bcBound(s, v.item, v.si), where: locUnder(locs, s.placed[v.key], locationId) }));
}, [s, L, variants, locationId, locs]);
const [counted, setCounted] = useState<Record<string, number>>({});
// The line being counted is held by its variant key, never by its position in `lines`. The list
// is rebuilt on every live refresh, and a size sorting earlier in the catalogue being inserted
// ahead of it would leave an index pointing at the neighbouring garment: the next Undo would then
// take one off a line that was counted correctly and leave the double-scan where it was.
const [activeKey, setActiveKey] = useState("");
const [scan, setScan] = useState<null | "single" | "live">(null);
const [live, setLive] = useState(false);
const [log, setLog] = useState<string[]>([]);
const [manual, setManual] = useState(false);
const [err, setErr] = useState("");
const [loaded, setLoaded] = useState(false);
const listRef = useRef<HTMLDivElement | null>(null);
// Restore this person's open count of this shelf. The tally is keyed on the signed-in user as
// well as the location: the phone is shared, and resuming somebody else's abandoned count under
// your own name is worse than starting again.
//
// It reads once per shelf and deliberately does not re-run on `lines`. The list is rebuilt on
// every live refresh, and rebuilding the tally from it dropped any key that had just left this
// shelf — the coordinator placing a size from the desktop while the trolley is being counted —
// which the write below then made permanent. The garments the counter had already found went
// with it, silently. Counts are held by key whether or not the key is still listed here.
const me = s.session.userId;
useEffect(() => {
setCounted(readCount(me, locationId)?.n ?? {});
setLoaded(true);
}, [me, locationId]);
useEffect(() => {
if (!loaded) return;
writeCount(me, locationId, counted);
}, [counted, me, locationId, loaded]);
useKeepAwake(true);
const total = lines.reduce((t, l) => t + (counted[l.key] ?? 0), 0);
const expectedAll = lines.reduce((t, l) => t + l.expected, 0);
const cur = lines.find((l) => l.key === activeKey) || lines[0];
const bump = useCallback((k: string, by: number) => {
setCounted((c) => ({ ...c, [k]: Math.max(0, (c[k] ?? 0) + by) }));
}, []);
/** A scanned code lands on its own line, whichever line was active — the barcode is the truth. */
const onCode = useCallback((raw: string) => {
const code = raw.trim();
const hit = s.barcodes[code];
const ix = hit ? lines.findIndex((l) => l.key === hit) : lines.findIndex((l) => l.code === code);
if (ix < 0) {
scanReject();
const known = Object.prototype.hasOwnProperty.call(s.barcodes, code);
// Never the barcode itself — only whether ThreadCount knew it. "unknown" in volume means
// labels are being printed outside the catalogue.
track("scan_miss", { kind: known ? "wrong_shelf" : "unknown" });
/* "Somewhere else" is only true when it IS somewhere. A code bound to a size that has never
been placed and never been stocked is on no shelf at all, and telling somebody to go and
look for it elsewhere sends them hunting for a garment nothing has ever recorded. Say which
of the two it is, and name the shelf when there is one to name. */
const placedAt = hit ? locs[s.placed[hit] || ""]?.name : "";
setErr(!known ? `${code} isnt a garment ThreadCount knows. Bind it to a size first — you can type it in on the garments page.`
: placedAt ? `${code} is on ${placedAt}, not this shelf.`
: `${code} isnt in this count. It hasnt been placed on a shelf, so it sits under “Not on a shelf”.`);
setLog((g) => [`${code} — not on this shelf`, ...g]);
return;
}
setActiveKey(lines[ix].key);
bump(lines[ix].key, 1);
setErr("");
setLog((g) => [`${variantName(byId[lines[ix].itemId], lines[ix].size)}`, ...g].slice(0, 8));
}, [s.barcodes, lines, bump, byId]);
if (loaded && !lines.length) {
return (
<>
<MTop title={locName} back />
<MRule />
<MBody><MEmpty title="Nothing on this shelf" sub="No garment has been placed here yet. Place sizes against a location from Inventory on the desktop, then come back." /></MBody>
</>
);
}
return (
<>
<MTop title={locName} right={`${total} / ${expectedAll}`} back />
<MRule n={total} of={expectedAll} />
<MError msg={err} onDismiss={() => setErr("")} />
{cur && (
<MPanel kicker="Now counting" kickerRight={<MInkLink label="Hands-free" onClick={() => { setScan("live"); setLive(true); }} />}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 26, letterSpacing: "-0.02em", lineHeight: 1.1 }}>
{variantName(byId[cur.itemId], cur.size)}
</div>
<div style={{ fontSize: 13, color: ON_DARK, marginTop: 6 }}>
{[cur.code || (cur.item.sku ? `SKU ${cur.item.sku}` : "No barcode bound"), cur.where].filter(Boolean).join(" · ")}
</div>
<MFigures counted={counted[cur.key] ?? 0} expected={cur.expected} />
</MPanel>
)}
<MSplit>
<MAction label="Scan" flex={2} glyph="scan" onClick={() => setScan("single")} />
<MAction label="Undo" flex={1} tone="grey" onClick={() => cur && bump(cur.key, -1)} disabled={!cur || (counted[cur.key] ?? 0) <= 0} />
</MSplit>
<MBody>
<div ref={listRef}>
<MSection label="Lines" right="Counted / expected" />
{lines.map((l) => {
const n = counted[l.key] ?? 0;
const on = !!cur && l.key === cur.key;
return (
<MRow key={l.key} onClick={() => setActiveKey(l.key)} attention={on}
mark={on ? "accent" : n === l.expected ? "ink" : "mute"}
title={`${variantName(byId[l.itemId], l.size)}`}
sub={[l.code || (l.item.sku ? `SKU ${l.item.sku}` : "No barcode bound"), l.where].filter(Boolean).join(" · ")}
right={
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, fontVariantNumeric: "tabular-nums" }}>
{/* The expected figure is the whole point of the row, so it is readable ink,
not the near-invisible neutral-400 it used to be drawn in. */}
{n}<span style={{ color: "var(--color-neutral-700)" }}>/{l.expected}</span>
</span>
} />
);
})}
</div>
<div style={{ padding: 16 }}>
{manual && cur ? (
<div style={{ border: "2px solid " + INK, background: "#fff", padding: 16 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>Counted for {variantName(byId[cur.itemId], cur.size)}</div>
{/* Keyed on the line so the box is rebuilt when the counter taps a different one. An
uncontrolled input keeps its first value, so it went on showing the figure typed
for the previous line under the new line's heading — read as "counted at 7", the
new line was then committed at 0 and the gap blamed on the shelf. */}
<input key={cur.key} type="number" inputMode="numeric" min={0} defaultValue={counted[cur.key] ?? 0} autoFocus style={{ ...inputStyle, marginTop: 8 }}
onChange={(e) => setCounted((c) => ({ ...c, [cur.key]: Math.max(0, parseInt(e.target.value || "0", 10) || 0) }))} />
<button onClick={() => setManual(false)} style={{ marginTop: 12, minHeight: 44, width: "100%", border: "2px solid " + INK, background: "transparent", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>Done</button>
</div>
) : (
<button onClick={() => setManual(true)} style={{ background: "none", border: 0, padding: "8px 0", color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>
Type a count instead for a label that wont scan
</button>
)}
</div>
</MBody>
<MAction label="Finish count" glyph="none" onClick={() => router.push(`/m/count/${locationId}/variance`)} />
{scan && (
<MScan
title="Scan a garment"
live={scan === "live"}
running={live}
onToggle={() => setLive((v) => !v)}
log={log}
onHit={(raw) => { onCode(raw); if (scan === "single") setScan(null); }}
onClose={() => { setScan(null); setLive(false); }}
figure={scan === "live" && cur ? (
<MPanel pad={14}>
<div style={{ fontSize: 13, color: ON_DARK }}>{variantName(byId[cur.itemId], cur.size)}</div>
<div style={{ display: "flex", alignItems: "baseline", gap: 14, marginTop: 4 }}>
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 40, lineHeight: 1, fontVariantNumeric: "tabular-nums" }}>{counted[cur.key] ?? 0}</span>
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 22, color: "var(--color-neutral-300)", fontVariantNumeric: "tabular-nums" }}>{cur.expected}</span>
<span style={{ marginLeft: "auto", fontSize: 12, color: ON_DARK }}>{total} / {expectedAll} on this shelf</span>
</div>
</MPanel>
) : undefined}
/>
)}
</>
);
}
+171
View File
@@ -0,0 +1,171 @@
"use client";
/* Variance — only the lines that don't match, what happens when the count commits, and the commit.
A gap at or over the facility's threshold has to carry a reason before anything is filed. */
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { UNPLACED, bcBound, formatInZone, locMap, locSubtree, locUnder, onhand, reorderAt, touched, variantName } from "@/lib/compute";
import { INK, MBar, MBody, MEmpty, MError, MRule, MTop, MPanel, MInkLink } from "@/components/m";
import { clearCount, readCount } from "@/lib/opencount";
const REASONS = ["At laundry", "Condemned", "Missing", "Other"];
export default function MVariance() {
const { s, mutate, busy } = useSnap();
const { L, byId, variants } = useDerived();
const router = useRouter();
const locationId = String(useParams().id || "");
const locs = useMemo(() => locMap(s), [s]);
const locName = locationId === UNPLACED ? "Not on a shelf" : locs[locationId]?.name || "Location";
const lines = useMemo(() => {
const sub = locationId === UNPLACED ? null : locSubtree(s, locationId);
// Exactly the set the counting screen lists, and it has to stay the same test. A placed size
// counts even with no history, and so does an unplaced size with a barcode bound to it —
// somebody stood at the counter and scanned that label onto that size, which is why the
// counting screen lets you count it. Leave that arm off here and a size counted on the phone
// has no row on this screen and no line in the payload: committing files a stocktake without
// it, the garments found on the trolley are never counted in, and clearCount() then wipes the
// tally that was the only record they had been found.
return variants
.filter((v) => (sub ? sub.has(s.placed[v.key] || "") : !s.placed[v.key] && (touched(s, L, v.key) || !!bcBound(s, v.item, v.si))))
.map((v) => ({ ...v, expected: onhand(s, L, v.key), code: bcBound(s, v.item, v.si), where: locUnder(locs, s.placed[v.key], locationId) }));
}, [s, L, variants, locationId, locs]);
const [counted, setCounted] = useState<Record<string, number> | null>(null);
const [savedAt, setSavedAt] = useState("");
const [reason, setReason] = useState<Record<string, string>>({});
const [accepted, setAccepted] = useState<Record<string, boolean>>({});
const [err, setErr] = useState("");
// The tally belongs to the person who took it, so it is read back under their own key — the
// counting screen writes it under theirs. When it was taken matters as much as what it says:
// a count resumed the next morning has had a night of issuing against it, and the screen should
// say when it was last touched rather than present a stale tally as if it were fresh.
const me = s.session.userId;
useEffect(() => {
const open = readCount(me, locationId);
setCounted(open?.n ?? {});
setSavedAt(open?.savedAt ?? "");
}, [me, locationId]);
const gate = Math.max(1, s.settings.varianceReason);
const off = useMemo(() => (counted ? lines.filter((l) => (counted[l.key] ?? 0) !== l.expected) : []), [counted, lines]);
const totalCounted = counted ? lines.reduce((t, l) => t + (counted[l.key] ?? 0), 0) : 0;
const totalExpected = lines.reduce((t, l) => t + l.expected, 0);
const needsReason = off.filter((l) => Math.abs((counted?.[l.key] ?? 0) - l.expected) >= gate && !reason[l.key]);
// What the shelf will look like once this commits — not what the commit does. Committing a count
// writes stock adjustments and the stocktake itself and nothing else; the reorder draft is a
// separate, deliberate step on Reorder, which is where the quantities can still be changed
// before anything goes to a supplier.
const willReorder = useMemo(() => {
if (!counted) return { lines: 0, units: 0 };
let n = 0, units = 0;
for (const l of lines) {
const after = counted[l.key] ?? 0;
const par = reorderAt(s, l.key);
if (after <= par && l.expected > par) { n++; units += Math.max(0, par * 2 - after); }
}
return { lines: n, units };
}, [counted, lines, s]);
const commit = useCallback(async () => {
if (!counted) return;
if (needsReason.length) { setErr(`A gap of ${gate} or more needs a reason — ${needsReason.length} line${needsReason.length === 1 ? "" : "s"} still to go.`); return; }
const payload = lines.map((l) => ({ itemId: l.itemId, si: l.si, counted: counted[l.key] ?? 0, reason: reason[l.key] || "" }));
const r = await mutate("stocktake.apply", { lines: payload, mode: "shelf", locationId: locationId === UNPLACED ? "" : locationId });
if (!r.ok) { setErr(r.error); return; }
clearCount(me, locationId);
// A count that leaves lines below par hands straight over to Reorder. Nothing is drafted by
// the commit itself, and a count that ends on the home screen is a count whose shortfall
// nobody ever goes back for.
router.push(willReorder.lines > 0 ? "/m/reorder" : "/m?counted=1");
}, [counted, lines, reason, needsReason.length, gate, mutate, me, locationId, router, willReorder.lines]);
if (!counted) return (<><MTop title="Variance" back /><MRule /><MBody /></>);
return (
<>
<MTop title="Variance" back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody>
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, letterSpacing: "-0.03em", lineHeight: 1.05 }}>
{off.length === 0 ? "Everything matches" : `${off.length} line${off.length === 1 ? "" : "s"} dont match`}
</h2>
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 8 }}>{locName} · counted {totalCounted} of {totalExpected} expected</p>
{savedAt && (
<p style={{ fontSize: 13, color: "var(--color-neutral-700)", marginTop: 4 }}>
Tallied {formatInZone(savedAt, s.tz, { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" })}.
{" "}Anything issued since then is already off the expected figure.
</p>
)}
</div>
{off.length === 0 ? (
<MEmpty title="No gaps to explain" sub="Every line came out at what the system expected. Commit the count to file it against this shelf." />
) : off.map((l) => {
const n = counted[l.key] ?? 0;
const d = n - l.expected;
const big = Math.abs(d) >= gate;
return (
<div key={l.key} style={{ padding: 16, background: "#fff", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, letterSpacing: "-0.02em" }}>{variantName(byId[l.itemId], l.size)}</div>
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 4 }}>{[l.code || (l.item.sku ? `SKU ${l.item.sku}` : "No barcode bound"), l.where].filter(Boolean).join(" · ")}</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em", color: "var(--color-accent-700)", fontVariantNumeric: "tabular-nums" }}>{d > 0 ? `+${d}` : `${-d}`}</div>
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 2 }}>{n} of {l.expected}</div>
</div>
</div>
<div style={{ display: "flex", gap: 8, marginTop: 14 }}>
<button onClick={() => router.push(`/m/count/${locationId}`)}
style={{ flex: 1, minHeight: 44, border: "2px solid " + INK, background: "transparent", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>Recount</button>
<button onClick={() => setAccepted((a) => ({ ...a, [l.key]: !a[l.key] }))} aria-pressed={!!accepted[l.key]}
style={{ flex: 1, minHeight: 44, border: "2px solid " + INK, background: accepted[l.key] ? INK : "transparent", color: accepted[l.key] ? "var(--color-bg)" : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>
{accepted[l.key] ? "Accepted" : "Accept"}
</button>
</div>
{big && (
<div style={{ marginTop: 14, padding: 14, background: "var(--color-bg)" }}>
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-accent-700)" }}>
A gap of {gate} or more needs a reason
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 10 }}>
{REASONS.map((r) => {
const on = reason[l.key] === r;
return (
<button key={r} onClick={() => setReason((x) => ({ ...x, [l.key]: on ? "" : r }))} aria-pressed={on}
style={{ minHeight: 44, padding: "0 14px", border: "2px solid " + INK, background: on ? INK : "transparent", color: on ? "var(--color-bg)" : INK, fontSize: 13, fontWeight: 700, cursor: "pointer" }}>{r}</button>
);
})}
</div>
</div>
)}
</div>
);
})}
<div style={{ padding: 16 }}>
<MPanel kicker="After this count">
<p style={{ fontSize: 14, lineHeight: 1.6, margin: 0 }}>
{willReorder.lines === 0
? "Nothing falls below par when this commits, so there is nothing to reorder."
: `${willReorder.lines} line${willReorder.lines === 1 ? "" : "s"} will be below par once this count commits — about ${willReorder.units} item${willReorder.units === 1 ? "" : "s"} to order. Committing orders nothing on its own: it takes you to Reorder, where you raise the draft.`}
</p>
<p style={{ fontSize: 13, lineHeight: 1.6, marginTop: 10, color: "var(--color-neutral-400)" }}>Nothing is sent to a supplier without approval.</p>
{willReorder.lines > 0 && <div style={{ marginTop: 14 }}><MInkLink label="Reorder" href="/m/reorder" /></div>}
</MPanel>
</div>
</MBody>
<MBar label={busy ? "Committing…" : "Commit count"} glyph="check" onClick={commit} disabled={busy || needsReason.length > 0}
sub={needsReason.length ? `${needsReason.length} gap${needsReason.length === 1 ? " still needs" : "s still need"} a reason` : undefined} />
</>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
/* Stocktake — choose what you're counting. One row per location that actually holds garments,
plus everything not yet placed, so nothing on the shelf is uncountable. */
import { useMemo } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { UNPLACED, bcBound, daysBetween, locSubtree, locTree, onhand, touched } from "@/lib/compute";
import { INK, MBody, MEmpty, MNav, MNote, MRow, MRule, MSection, MTop } from "@/components/m";
/* "Last counted 0 days ago" and "1 days ago" are how a shelf counted this morning used to read. */
function lastCounted(last: string | undefined, today: string): string {
if (!last) return "Never counted";
const n = daysBetween(last, today);
if (n <= 0) return "Counted today";
if (n === 1) return "Counted yesterday";
return `Last counted ${n} days ago`;
}
export default function MCountStart() {
const { s } = useSnap();
const { L, variants } = useDerived();
const rows = useMemo(() => {
const lastAt: Record<string, string> = {};
for (const t of s.stocktakes) if (t.mode !== "preloved" && t.locationId && !lastAt[t.locationId]) lastAt[t.locationId] = t.date;
const out = locTree(s).map(({ loc, depth }) => {
const sub = locSubtree(s, loc.id);
// Placed on the shelf is enough to make a shelf countable. A location holding only sizes
// that have never been stocked is exactly the shelf someone needs to count in.
const mine = variants.filter((v) => sub.has(s.placed[v.key] || ""));
return { id: loc.id, name: loc.name, kind: loc.kind, depth, lines: mine.length, units: mine.reduce((t, v) => t + onhand(s, L, v.key), 0), last: lastAt[loc.id] as string | undefined };
}).filter((r) => r.lines > 0);
// Only the unplaced bucket needs a test at all — without one it would list the whole
// catalogue. A bound barcode counts as much as stock history does: somebody stood at the
// counter with the garment in hand and scanned its label onto that size, which says the size
// physically exists even when no stock figure does. The counting and variance screens filter
// the unplaced bucket with exactly this expression and all three have to agree — a room whose
// unplaced sizes are all barcode-bound and never yet stocked otherwise gets no "Not on a shelf
// yet" row here, and the one screen that could count them in is unreachable from the menu.
const loose = variants.filter((v) => !s.placed[v.key] && (touched(s, L, v.key) || !!bcBound(s, v.item, v.si)));
if (loose.length) out.push({ id: UNPLACED, name: "Not on a shelf yet", kind: "", depth: 0, lines: loose.length, units: loose.reduce((t, v) => t + onhand(s, L, v.key), 0), last: undefined });
return out;
}, [s, L, variants]);
return (
<>
<MTop title="Stocktake" right={`${rows.length} location${rows.length === 1 ? "" : "s"}`} />
<MRule />
<MBody>
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, letterSpacing: "-0.03em", lineHeight: 1.05 }}>Where are you counting?</h2>
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 10, lineHeight: 1.6 }}>
Scan every garment on the shelf. Each scan adds one to that line, and the expected figure stays on screen the whole way.
</p>
</div>
{rows.length === 0 ? (
<MEmpty
title="Nothing to count yet"
sub="A location shows up here once garments are placed on it. Set your shelves up in Settings on the desktop, then place each size against one."
/>
) : (
<>
<MSection label="Locations" right="Lines · units" />
{rows.map((r) => (
<MRow key={r.id} href={`/m/count/${r.id}`} mark={r.id === UNPLACED ? "mute" : "ink"}
title={<span style={{ paddingLeft: r.depth * 14 }}>{r.name}</span>}
sub={<span style={{ paddingLeft: r.depth * 14 }}>{lastCounted(r.last, s.today)}</span>}
right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, fontVariantNumeric: "tabular-nums" }}>{r.lines} · {r.units}</span>} />
))}
</>
)}
<MNote>A count stays open until you commit it, so you can put the phone down halfway along a shelf and pick it up again.</MNote>
</MBody>
<MNav />
</>
);
}
+220
View File
@@ -0,0 +1,220 @@
"use client";
/* Issue — 1B, person first. Their sizes are already known, so the list is what they'd normally take;
scanning adds anything else. What they may hold and the managers approval are both checked before
the bag is handed over, not after. */
import { useCallback, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { approvalRemaining, capCheck, garmentForGroup, garmentForStyle, genderLabel, groupBucket, groupsLabel, inBucket, initialRemaining, isNursing, isPantItem, isTopItem, label, money, onhand, sizeIndexOf, splitKey, variantName } from "@/lib/compute";
import MScan from "@/components/MScan";
import { INK, MBar, MBody, MEmpty, MError, MRow, MRule, MSection, MTop, MStepper } from "@/components/m";
import { MEntitlement, MPersonHead, useHeld } from "@/components/MPerson";
type Line = { key: string; itemId: string; si: number; size: string; name: string; qty: number; cost: number; onHand: number };
export default function MIssue() {
const { s, mutate, busy } = useSnap();
const { L, byId } = useDerived();
const router = useRouter();
const id = String(useParams().staffId || "");
const st = s.staff.find((x) => x.id === id);
const held = useHeld(s, id);
const [cart, setCart] = useState<Line[]>([]);
const [scan, setScan] = useState(false);
const [err, setErr] = useState("");
const [override, setOverride] = useState(false);
const [done, setDone] = useState<string | null>(null);
/* What this person would normally be handed: their groups garments, in the cut they are offered,
in their recorded size. Both questions are the server's own — a rule written again here would
suggest a garment the counter then refuses. Blank and Either are offered every cut. */
const suggested = useMemo(() => {
if (!st) return [];
const bucket = groupBucket(st.group);
const out: Line[] = [];
for (const it of s.catalog) {
if (it.archived) continue;
if (bucket && !inBucket(it, bucket)) continue;
if (!garmentForStyle(it, st.uniformStyle)) continue;
const want = isTopItem(it) ? st.top : isPantItem(it) ? st.pants : "";
const si = want ? sizeIndexOf(it, want) : -1;
if (si < 0) continue;
const k = `${it.id}:${si}`;
out.push({ key: k, itemId: it.id, si, size: String(it.sizes[si]), name: `${variantName(it, it.sizes[si])}`, qty: 1, cost: it.cost, onHand: onhand(s, L, k) });
}
return out;
}, [s, st, L]);
const inCart = useCallback((k: string) => cart.find((c) => c.key === k), [cart]);
const add = useCallback((l: Line) => {
setErr("");
setCart((c) => {
const at = c.findIndex((x) => x.key === l.key);
if (at < 0) return [...c, { ...l, qty: 1 }];
const next = [...c]; next[at] = { ...next[at], qty: next[at].qty + 1 }; return next;
});
}, []);
const setQty = useCallback((k: string, n: number) => setCart((c) => (n <= 0 ? c.filter((x) => x.key !== k) : c.map((x) => (x.key === k ? { ...x, qty: n } : x)))), []);
const onCode = useCallback((raw: string) => {
const k = s.barcodes[raw.trim()];
if (!k) { setErr(`${raw.trim()} isnt a garment ThreadCount knows.`); return; }
const { itemId, si } = splitKey(k);
const it = byId[itemId];
if (!it || it.archived) { setErr("That garment is discontinued."); return; }
add({ key: k, itemId, si, size: String(it.sizes[si]), name: `${variantName(it, it.sizes[si])}`, qty: 1, cost: it.cost, onHand: onhand(s, L, k) });
}, [s, byId, L, add]);
if (!st) return (<><MTop title="Issue" back /><MRule /><MBody><MEmpty title="No such staff member" /></MBody></>);
const cartQty = cart.reduce((t, c) => t + c.qty, 0);
const heldQty = held.reduce((t, h) => t + h.qty, 0);
const total = cart.reduce((t, c) => t + c.qty * c.cost, 0);
const nursing = isNursing(s, st);
/* The one question this screen asks: after this bag, is this person still inside the six sets one
person holds? Six at any time, every group, nursing included — so the sum is what they have out
now plus what is on the counter, and nothing in it starts again in July. It is the server's own
function, so the warning here and the refusal there cannot drift apart; the last time this screen
kept a private copy of the sum it demanded a tick the server never wanted. */
const cap = capCheck(s, st, cart);
const over = cap.over;
/* Garments in the cart that are not for this person's staff group, and garments that are not the
cut they are offered. The server refuses either without the coordinator override, and records
them as outside the group or outside the style rather than as over the ceiling, so the same tick
is offered for any of the three reasons. garmentForGroup() and garmentForStyle() are the
server's own questions, asked here so the screen and the refusal cannot drift apart. */
const cartItems = [...new Set(cart.map((c) => c.itemId))].map((iid) => byId[iid])
.filter((it): it is NonNullable<typeof it> => !!it);
const offGroup = cartItems.filter((it) => !garmentForGroup(it, st.group));
const offStyle = cartItems.filter((it) => !garmentForStyle(it, st.uniformStyle));
/* One refusal naming every reason that applies, composed as the server composes it: a clause per
reason, the ceiling among them, and the sentence about the tick once at the end, because one
tick answers all of them. A message that named the first and stopped would have the coordinator
tick for that and wave the rest through without anybody having been told about them. The count
is of distinct garments across both lists — one garment wrong on both counts is still "it". */
const wrongCount = new Set([...offGroup, ...offStyle].map((it) => it.id)).size;
const wrongNote = wrongCount
? `${[
offGroup.length ? `${offGroup.map((it) => `${it.item} is for ${groupsLabel(it.groups)}`).join("; ")}${(st.group || "").trim() ? `${st.first} ${st.last} is in ${st.group.trim()}` : `${st.first} ${st.last} has no staff group recorded`}` : "",
offStyle.length ? `${offStyle.map((it) => `${it.item} is the ${genderLabel(it.gender)} cut`).join("; ")}${st.first} ${st.last} is set to ${st.uniformStyle}` : "",
over ? `It would also take them past what one person holds: ${cap.note}` : "",
].filter(Boolean).join(". ")}. Tick the coordinator override to issue ${wrongCount === 1 ? "it" : "them"} anyway.`
: "";
const overrideWhy = [offGroup.length ? "outside their staff group" : "", offStyle.length ? "outside their uniform style" : "", over ? "above what one person holds" : ""]
.filter(Boolean).reduce((a, b, i, all) => (i === 0 ? b : i === all.length - 1 ? `${a} and ${b}` : `${a}, ${b}`), "");
/* Garments of the starting kit this record still owes. What they are owed on starting, said on the
shelf list below — never a term in whether this collection is allowed. A new starter holds
nothing and takes three sets, and three is inside six, so the kit that used to need a coordinator
override to hand over now goes through as the ordinary first issue it always was. */
const kitLeft = initialRemaining(s, st) ?? 0;
const sets = approvalRemaining(s, st.id);
/* A managers approval is counted in SETS — one top and one pair of trousers — so a set is spent per top
or per pair of trousers, whichever side of the pair is bigger, and never by anything else. A
jacket, a vest or maternity wear is neither half of a set and costs the ward nothing off the
approval. This must stay identical to the desktop Issue screen: counting garments instead of
sets here quietly spent a whole approved set on a single fleece, and spent only half of what
the manager signed for when someone took four tops. It is a separate control from the six sets
anybody may hold: the approval is what pays for the garments, the ceiling is how much uniform one
person walks around with, and a nurse has to satisfy both. */
const cartTops = cart.reduce((t, c) => t + (isTopItem(byId[c.itemId]) ? c.qty : 0), 0);
const cartPants = cart.reduce((t, c) => t + (isPantItem(byId[c.itemId]) ? c.qty : 0), 0);
const short = cart.find((c) => c.qty > c.onHand);
/* What they hold against the ceiling, said in the section headers that are already on the screen.
Without it the counter cant tell a new starter collecting the kit theyre owed from somebody
drawing a seventh set. */
const holdsRight = `${cap.sets}/${cap.cap} sets · ${heldQty} item${heldQty === 1 ? "" : "s"}`;
const notYetLabel = kitLeft > 0 ? `Starting kit — ${kitLeft} still to issue`
: "Their size, not yet issued";
const commit = async () => {
if (!cart.length) return;
if (short) { setErr(`Only ${short.onHand} of ${short.name} on the shelf.`); return; }
// The reason comes from the same function the server refuses with, so nobody is told one thing
// here and another when they press the button.
if (wrongCount && !override) { setErr(wrongNote); return; }
if (over && !override) { setErr(cap.note); return; }
const r = await mutate<{ stock: number; apDeducted: number; apRemaining: number }>("issue.create", {
// The tick and nothing else. An override is a record that somebody knowingly bent a rule, so
// only somebody may set it: a new starter collecting the kit they are owed has bent nothing,
// and it now goes through on its own merits.
staffId: st.id, override, apDeduct: nursing ? Math.min(sets, Math.max(cartTops, cartPants)) : 0,
lines: cart.map((c) => ({ itemId: c.itemId, si: c.si, qty: c.qty, src: "stock" })),
});
if (!r.ok) { setErr(r.error); return; }
setDone(`${cartQty} item${cartQty === 1 ? "" : "s"} issued to ${st.first} ${st.last}.`);
setCart([]);
};
if (done) {
return (
<>
<MTop title="Issued" />
<MRule />
<MBody>
<MEmpty title={done} sub="A replenishment draft has been topped up on Ordering. Nothing is sent to a supplier without approval." />
</MBody>
<MBar label="Back to the person" href={`/m/person/${st.id}`} glyph="arrow" />
</>
);
}
return (
<>
<MTop title="Issue" back right={cartQty ? `${cartQty} to issue` : undefined} />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody>
<MPersonHead s={s} st={st} sub={<MEntitlement s={s} st={st} cart={cart} />} />
{cart.length > 0 && (
<>
<MSection label="Issuing now" right={money(total)} />
{cart.map((c) => (
<MRow key={c.key} mark="accent" attention title={c.name} sub={`${money(c.cost)} · ${c.onHand} on the shelf`}
right={<MStepper n={c.qty} onChange={(n) => setQty(c.key, n)} max={Math.max(1, c.onHand)} />} />
))}
{(over || wrongCount > 0) && (
<label style={{ display: "flex", alignItems: "center", gap: 10, padding: "14px 16px", background: "#fff", borderBottom: "1px solid var(--color-divider)", fontSize: 14 }}>
<input type="checkbox" checked={override} onChange={(e) => setOverride(e.target.checked)} style={{ width: 22, height: 22 }} />
<span>Coordinator override record this {overrideWhy}</span>
</label>
)}
</>
)}
<MSection label="Currently holds" right={holdsRight} />
{held.length === 0 && <div style={{ padding: "18px 16px", fontSize: 14, color: "var(--color-neutral-600)" }}>Nothing out at the moment.</div>}
{held.map((h) => {
const it = byId[h.itemId];
const k = h.key;
return (
<MRow key={k} title={h.name} sub={`${h.qty} held`}
right={<button onClick={() => add({ key: k, itemId: h.itemId, si: h.si, size: h.size, name: h.name, qty: 1, cost: it?.cost ?? 0, onHand: onhand(s, L, k) })}
style={{ width: 56, height: 44, border: "2px solid " + INK, background: inCart(k) ? INK : "transparent", color: inCart(k) ? "var(--color-bg)" : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, cursor: "pointer" }}>+ 1</button>} />
);
})}
{suggested.filter((l) => !held.some((h) => h.key === l.key)).length > 0 && (
<>
<MSection label={notYetLabel} />
{suggested.filter((l) => !held.some((h) => h.key === l.key)).map((l) => (
<MRow key={l.key} attention mark="accent" title={l.name}
sub={<span style={{ color: l.onHand > 0 ? "var(--color-accent-700)" : "var(--color-neutral-600)" }}>{l.onHand > 0 ? "Not yet issued" : "None on the shelf"}</span>}
right={<button onClick={() => add(l)} disabled={l.onHand <= 0}
style={{ width: 56, height: 44, border: "2px solid " + INK, background: inCart(l.key) ? INK : "transparent", color: inCart(l.key) ? "var(--color-bg)" : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, cursor: l.onHand > 0 ? "pointer" : "not-allowed", opacity: l.onHand > 0 ? 1 : 0.4 }}>+ 1</button>} />
))}
</>
)}
<div style={{ padding: "18px 16px 24px", fontSize: 14, color: "var(--color-neutral-700)" }}>Scan to add anything not on this list.</div>
</MBody>
{cart.length === 0
? <MBar label="Scan to add" glyph="scan" onClick={() => setScan(true)} />
: <MBar label={busy ? "Recording…" : `Issue ${cartQty} item${cartQty === 1 ? "" : "s"}`} glyph="check" onClick={commit} disabled={busy} sub={money(total)} />}
{scan && <MScan title="Scan a garment" onHit={(r) => { onCode(r); setScan(false); }} onClose={() => setScan(false)} />}
</>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
/* Issue starts with the person: their sizes, allowance and approvals all hang off the record,
so choosing them first is what lets the app check an issue before the garments leave the shelf. */
import { useMemo, useState } from "react";
import { useSnap } from "@/lib/client";
import { ccOf, staffName } from "@/lib/compute";
import { INK, MBody, MEmpty, MRow, MRule, MSection, MTop, inputStyle } from "@/components/m";
export default function MIssuePick() {
const { s } = useSnap();
const [q, setQ] = useState("");
const list = useMemo(() => {
const needle = q.trim().toLowerCase();
const active = s.staff.filter((x) => !x.inactive);
if (!needle) {
// No query: whoever was served most recently, so the usual faces are one tap away.
const seen: Record<string, string> = {};
for (const i of s.issues) seen[i.staffId] = i.date > (seen[i.staffId] || "") ? i.date : seen[i.staffId];
return [...active].sort((a, b) => (seen[b.id] || "").localeCompare(seen[a.id] || "")).slice(0, 12);
}
return active.filter((x) => `${x.first} ${x.last} ${x.num} ${x.dept}`.toLowerCase().includes(needle)).slice(0, 40);
}, [s, q]);
return (
<>
<MTop title="Issue" back right={`${s.staff.filter((x) => !x.inactive).length} on the register`} />
<MRule />
<MBody>
<div style={{ padding: 16, borderBottom: "2px solid " + INK }}>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name or staff number" autoFocus
aria-label="Search the staff register" style={inputStyle} />
</div>
<MSection label={q.trim() ? "Matches" : "Recently served"} />
{list.length === 0
? <MEmpty title="Nobody matches that" sub="Try a surname or a staff number. New starters are added on the desktop." />
: list.map((st) => (
<MRow key={st.id} href={`/m/issue/${st.id}`} mark="accent"
title={staffName(st)}
sub={[st.num, st.dept || st.group, ccOf(s, st) && `CC ${ccOf(s, st)}`].filter(Boolean).join(" · ")} />
))}
</MBody>
</>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
/* Reprint a label. Short by design: it exists because a garment nobody can scan silently vanishes
from every count. Only sizes with a real supplier barcode can be reprinted — ThreadCount's
internal fallback code appears nowhere on a garment, so printing it would help nobody. */
import { useEffect, useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { bcBound, label, variantName } from "@/lib/compute";
import { isNative } from "@/lib/nativescan";
import MScan from "@/components/MScan";
import { INK, IconScan, MBar, MBody, MEmpty, MError, MNote, MRow, MRule, MSection, MStepper, MTop, inputStyle } from "@/components/m";
const REASONS = ["Worn off in the laundry", "Torn", "Never labelled", "Other"];
export default function MLabel() {
const { s } = useSnap();
const { byId, variants } = useDerived();
const [q, setQ] = useState("");
const [pick, setPick] = useState<string | null>(null);
const [reason, setReason] = useState("");
const [copies, setCopies] = useState(6);
const [scan, setScan] = useState(false);
const [err, setErr] = useState("");
/* The Android shell cannot print. Its WebView opens no second window, so the label sheet would
replace the app, and window.print() doesn't exist there — the button looked like it worked and
stranded the person on a page with nothing to do. Read after mount: the server render doesn't
know which shell it is being sent to. */
const [inApp, setInApp] = useState(false);
useEffect(() => { setInApp(isNative()); }, []);
const rows = useMemo(() => {
const needle = q.trim().toLowerCase();
return variants
.map((v) => ({ ...v, code: bcBound(s, v.item, v.si), name: `${variantName(byId[v.itemId], v.size)}` }))
.filter((r) => r.code)
.filter((r) => !needle || `${r.name} ${r.code} ${r.item.sku}`.toLowerCase().includes(needle));
}, [s, variants, byId, q]);
const chosen = rows.find((r) => r.key === pick);
const printable = chosen && chosen.code;
return (
<>
<MTop title="Reprint label" back right={chosen ? undefined : `${rows.length} labelled size${rows.length === 1 ? "" : "s"}`} />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody>
{!chosen ? (
<>
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 28, letterSpacing: "-0.03em", lineHeight: 1.05 }}>Barcode gone</h2>
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 10, lineHeight: 1.6 }}>
A garment nobody can scan drops out of every count. Find it by code or description and print a fresh label.
</p>
</div>
<div style={{ padding: 16, borderBottom: "2px solid " + INK, display: "flex", gap: 8 }}>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Code or description" autoFocus
aria-label="Find a garment" style={{ ...inputStyle, flex: 1 }} />
<button onClick={() => setScan(true)} aria-label="Scan a working label"
style={{ width: 56, minHeight: 48, border: "2px solid " + INK, background: "var(--color-accent)", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer" }}>
<IconScan />
</button>
</div>
<MSection label="Sizes with a supplier barcode" />
{rows.length === 0
? <MEmpty title="Nothing matches" sub="Only sizes with a supplier barcode bound to them can be reprinted. Bind one by scanning the size on the desktop." />
: rows.slice(0, 40).map((r) => (
<MRow key={r.key} onClick={() => setPick(r.key)} mark="ink" title={r.name} sub={`${r.code}${r.item.sku ? ` · ${r.item.sku}` : ""}`} />
))}
</>
) : (
<>
<div style={{ padding: "20px 16px", background: "#fff", borderBottom: "2px solid " + INK }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em" }}>{chosen.name}</div>
<div style={{ fontSize: 14, color: "var(--color-neutral-600)", marginTop: 6 }}>{chosen.code}</div>
<button onClick={() => { setPick(null); setReason(""); }} style={{ marginTop: 12, background: "none", border: 0, padding: 0, color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Choose a different garment</button>
</div>
<MSection label="Why is it being reprinted?" />
<div style={{ padding: 16, display: "flex", flexWrap: "wrap", gap: 8 }}>
{REASONS.map((r) => {
const on = reason === r;
return (
<button key={r} onClick={() => setReason(on ? "" : r)} aria-pressed={on}
style={{ minHeight: 48, padding: "0 14px", border: "2px solid " + INK, background: on ? INK : "transparent", color: on ? "var(--color-bg)" : INK, fontSize: 14, fontWeight: 700, cursor: "pointer" }}>{r}</button>
);
})}
</div>
<MSection label="Copies" />
<div style={{ padding: 16, display: "flex", alignItems: "center", gap: 16 }}>
<span style={{ flex: 1, fontSize: 14, color: "var(--color-neutral-700)" }}>Six to an A4 sheet.</span>
<MStepper n={copies} onChange={setCopies} min={1} max={24} />
</div>
<MNote>The label carries the same barcode the supplier printed, so it scans identically to the ones still on the shelf.</MNote>
{inApp && (
<MNote tone="warn">
Printing is a desktop job the app can&rsquo;t open a label sheet. Open ThreadCount
on the desktop site, find <b>{chosen.name}</b> under {chosen.code}, and print it
from there.
</MNote>
)}
</>
)}
</MBody>
{chosen && (
<MBar label={inApp ? "Print it on the desktop" : `Print ${copies} label${copies === 1 ? "" : "s"}`} glyph="printer"
disabled={inApp}
onClick={() => {
if (!printable) { setErr("That size has no supplier barcode bound to it."); return; }
const url = `/print/labels?code=${encodeURIComponent(chosen.code)}&copies=${copies}&reason=${encodeURIComponent(reason)}`;
window.open(url, "_blank", "noopener");
}} />
)}
{scan && <MScan title="Scan a working label" onHit={(raw) => {
const k = s.barcodes[raw.trim()];
if (k) { setPick(k); setQ(""); } else setErr(`${raw.trim()} isnt a garment ThreadCount knows.`);
setScan(false);
}} onClose={() => setScan(false)} />}
</>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { redirect } from "next/navigation";
import { currentUser } from "@/lib/session";
import { buildSnapshot } from "@/lib/snapshot";
import { SnapshotProvider } from "@/lib/client";
export const dynamic = "force-dynamic";
/* Everything that needs a signed-in coordinator. Sending them to /m/login rather than /auth keeps
them in the app's own world: /auth is the website's two-pane sign-in, which is a jarring thing
to meet on a phone halfway through opening an app. */
export default async function MobileAppLayout({ children }: { children: React.ReactNode }) {
const user = await currentUser();
if (!user) redirect("/m/login");
const snap = await buildSnapshot(user);
return <SnapshotProvider snap={snap}>{children}</SnapshotProvider>;
}
+54
View File
@@ -0,0 +1,54 @@
/* What a tap looks like before the server answers, for the counter app.
*
* The twin of app/my/(app)/loading.tsx, and here for the same reason: every screen under /m is
* rendered from its own server query, App Router keeps the previous screen fully painted until that
* query comes back, and on linen-room wifi that is seconds in which nothing acknowledges the tap.
* People tap again — and on this app the second tap can land on a different row.
*
* It draws the app's own chrome (the 56px ink bar and the 4px accent rule, the shape MTop and MRule
* make) so the change reads as "loading" rather than "gone", and deliberately not the tab bar: the
* nav belongs to the four screens that draw it, and painting one here would flash it into existence
* on the way to a detail screen that has none. The bar carries no screen title for the same reason
* — this one fallback covers every route in the group, so any title would be wrong somewhere.
*/
const INK = "#201e1d";
const GROUND = "#f3f2f2";
/** A grey block standing in for a line of text. Sized in the same 2px system as everything else. */
function Bar({ w, h = 16 }: { w: string; h?: number }) {
return <div style={{ width: w, height: h, background: "var(--color-neutral-200)" }} />;
}
export default function CounterLoading() {
return (
<>
<header className="tcx-topbar" style={{
height: 56, flex: "0 0 56px", background: INK, color: GROUND, display: "flex", alignItems: "center",
paddingLeft: 16, paddingRight: 16,
backgroundImage: "linear-gradient(to bottom, rgba(243,242,242,0.16) 0 1px, transparent 1px)",
backgroundPosition: "0 env(safe-area-inset-top, 0px)", backgroundRepeat: "no-repeat", backgroundSize: "100% 1px",
}}>
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.06em", textTransform: "uppercase" }}>
One moment
</span>
</header>
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
<div style={{ flex: 1, overflow: "hidden", background: GROUND }} aria-busy="true">
{/* Announced once, quietly. The blocks below are decoration and say nothing. */}
<div role="status" style={{ padding: "20px 16px 0", fontSize: 13, color: "var(--color-neutral-600)" }}>Loading</div>
<div style={{ padding: "16px 16px 0", display: "grid", gap: 10 }} aria-hidden="true">
<Bar w="60%" h={22} />
<Bar w="40%" />
</div>
<div style={{ marginTop: 24, display: "grid", gap: 2 }} aria-hidden="true">
{[0, 1, 2, 3].map((i) => (
<div key={i} style={{ background: "#fff", padding: "18px 16px", display: "grid", gap: 8 }}>
<Bar w="55%" h={18} />
<Bar w="35%" h={12} />
</div>
))}
</div>
</div>
</>
);
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
/* Everything else the app does.
*
* There used to be an "On the desktop" section here listing five things the phone couldn't do —
* greyed out, untappable, and so just a list of disappointments in the middle of a menu. A menu
* should be things you can do. The catalogue moved onto the phone rather than staying on that
* list; the rest are simply not advertised here any more. */
import { useMemo } from "react";
import { useSnap } from "@/lib/client";
import { OPEN_STATUSES } from "@/lib/compute";
import { MBody, MNav, MRow, MRule, MSection, MTop } from "@/components/m";
export default function MMore() {
const { s } = useSnap();
const activeItems = useMemo(() => s.catalog.filter((i) => !i.archived).length, [s.catalog]);
const counts = useMemo(() => {
const waiting = s.pickups.filter((p) => !p.pickedUp).length;
const incoming = s.orders.filter((o) => OPEN_STATUSES.includes(o.status) && o.status !== "Draft").length;
const rounds = s.pickups.filter((p) => !p.pickedUp && p.deliveredTo).length;
return { waiting, incoming, rounds };
}, [s]);
return (
<>
<MTop title="More" />
<MRule />
<MBody>
<MSection label="Everything else" />
<MRow href="/m/receive" mark="ink" title="Receive a delivery" sub={counts.incoming ? `${counts.incoming} order${counts.incoming === 1 ? "" : "s"} on their way` : "Nothing on order"} />
<MRow href="/m/pickups" mark={counts.waiting ? "accent" : "ink"} attention={counts.waiting > 0} title="Pickup call list" sub={counts.waiting ? `${counts.waiting} waiting to be collected` : "Nobody waiting"} />
<MRow href="/m/rounds" mark="ink" title="Delivery round" sub={counts.rounds ? `${counts.rounds} to drop off` : "Nothing loaded"} />
<MRow href="/m/label" mark="ink" title="Reprint a label" sub="For a barcode that has worn off" />
<MRow href="/m/variance" mark="ink" title="Variance over time" sub="What keeps going missing" />
<MRow href="/m/catalogue" mark="ink" title="Catalogue" sub={`${activeItems} garment${activeItems === 1 ? "" : "s"}, sizes and pricing`} />
<MRow href="/m/settings" mark="ink" title="Settings" sub={s.session.name} />
</MBody>
<MNav />
</>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
/* Home — today. Four figures, whats just happened, and a way into a count. */
import Link from "next/link";
import { useMemo } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { countsAsIssued, daysBetween, label, longLabel, onhand, touched, reorderAt, staffName, variantName } from "@/lib/compute";
import { INK, IconRight, MBody, MNav, MRow, MRule, MSection, MTopBrand } from "@/components/m";
function Stat({ n, l, hot }: { n: string; l: string; hot?: boolean }) {
return (
<div style={{ padding: "18px 16px 16px", borderRight: "1px solid var(--color-divider)", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 44, lineHeight: 1, letterSpacing: "-0.03em", fontVariantNumeric: "tabular-nums", color: hot ? "var(--color-accent-700)" : INK }}>{n}</div>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginTop: 8 }}>{l}</div>
</div>
);
}
export default function MHome() {
const { s } = useSnap();
const { L, byId, staffById, variants } = useDerived();
const d = useMemo(() => {
const today = s.today;
let issued = 0, returned = 0;
for (const i of s.issues) {
if (i.date === today) issued += i.qty;
if (i.returned?.date === today) returned += i.qty;
}
const low = variants.filter((v) => touched(s, L, v.key) && onhand(s, L, v.key) <= reorderAt(s, v.key));
const lastCount = s.stocktakes.find((t) => t.mode !== "preloved");
const since = lastCount ? daysBetween(lastCount.date, today) : null;
// Recent activity, newest first. One line per person per day per kind — four garments handed
// to the same nurse in one go is one thing that happened, not four.
const grouped: Record<string, { staffId: string; kind: "Issued" | "Returned"; at: string; qty: number }> = {};
for (const i of s.issues.slice(-120)) {
const add = (kind: "Issued" | "Returned", at: string) => {
const k = `${i.staffId}|${kind}|${at}`;
(grouped[k] ||= { staffId: i.staffId, kind, at, qty: 0 }).qty += i.qty;
};
add("Issued", i.date);
if (i.returned) add("Returned", i.returned.date);
}
const recent = Object.values(grouped)
.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))
.slice(0, 3)
.map((g) => ({
title: `${staffName(staffById[g.staffId], "Staff")}${g.qty} item${g.qty === 1 ? "" : "s"}`,
sub: `${g.kind} · ${g.at === today ? "today" : g.at}`,
mark: "ink" as const, href: `/m/person/${g.staffId}`, at: g.at,
})) as { title: string; sub: string; mark: "ink" | "accent"; href?: string; at: string }[];
// A line AT its reorder level is in `low` on purpose (reorder now, not once it's short), but
// "Below par · 3 of 3" reads as a contradiction on the phone, so name the two states apart.
for (const v of low.slice(0, 2)) {
const oh = onhand(s, L, v.key), par = reorderAt(s, v.key);
recent.push({ title: `${variantName(byId[v.itemId], v.size)}`, sub: `${oh < par ? "Below par" : "At par"} · ${oh} of ${par}`, mark: "accent", href: `/m/stock`, at: "" });
}
return { issued, returned, low: low.length, since, recent };
}, [s, L, byId, staffById, variants]);
const fac = [s.settings.facility, s.settings.location].filter(Boolean).join(" · ");
const dateLine = new Date(+s.today.slice(0, 4), +s.today.slice(5, 7) - 1, +s.today.slice(8, 10))
.toLocaleDateString("en-AU", { weekday: "long", day: "numeric", month: "long" });
return (
<>
<MTopBrand facility={fac} />
<MRule />
<MBody>
<div style={{ padding: "20px 16px 24px", borderBottom: "2px solid " + INK }}>
<div style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>{dateLine}</div>
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 44, letterSpacing: "-0.03em", lineHeight: 1, marginTop: 10 }}>Today</h2>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr" }}>
<Stat n={String(d.issued)} l="Issued" />
<Stat n={String(d.returned)} l="Returned" />
<Stat n={String(d.low)} l="Below par" hot={d.low > 0} />
<Stat n={d.since === null ? "—" : `${d.since}d`} l="Since count" hot={d.since !== null && d.since > 30} />
</div>
<MSection label="Recent" />
{d.recent.length === 0
? <div style={{ padding: "28px 16px", fontSize: 14, color: "var(--color-neutral-600)" }}>Nothing has moved yet today.</div>
: d.recent.map((r, i) => (
<MRow key={i} mark={r.mark} attention={r.mark === "accent"} href={r.href}
title={r.title}
sub={<span style={{ color: r.mark === "accent" ? "var(--color-accent-700)" : undefined }}>{r.sub}</span>} />
))}
<div style={{ padding: 16, display: "grid", gap: 12 }}>
<Link href="/m/count" style={{ display: "flex", alignItems: "center", gap: 12, minHeight: 64, padding: "0 20px", border: "2px solid " + INK, color: INK, textDecoration: "none", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase" }}>
<span style={{ flex: 1 }}>Start a count</span><IconRight />
</Link>
<Link href="/m/issue" style={{ display: "flex", alignItems: "center", gap: 12, minHeight: 64, padding: "0 20px", border: "2px solid " + INK, color: INK, textDecoration: "none", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase" }}>
<span style={{ flex: 1 }}>Issue to someone</span><IconRight />
</Link>
<Link href="/m/more" style={{ display: "flex", alignItems: "center", gap: 12, minHeight: 52, padding: "0 20px", color: "var(--color-neutral-700)", textDecoration: "none", fontSize: 13, fontWeight: 600, letterSpacing: "0.06em", textTransform: "uppercase" }}>
<span style={{ flex: 1 }}>Deliveries, pickups, rounds and more</span><IconRight size={18} />
</Link>
</div>
</MBody>
<MNav />
</>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
/* Size exchange — one movement, not a return followed by an issue. What comes back, what goes out,
and the staff record updated so nobody hands them the wrong size again next month. */
import { useCallback, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { isPantItem, isTopItem, key, label, onhand, staffName } from "@/lib/compute";
import MScan from "@/components/MScan";
import { GROUND, INK, MBar, MBody, MChips, MEmpty, MError, MRow, MRule, MSection, MTop } from "@/components/m";
import { useHeld, type Held } from "@/components/MPerson";
export default function MExchange() {
const { s, mutate, busy } = useSnap();
const { L, byId } = useDerived();
const router = useRouter();
const id = String(useParams().id || "");
const st = s.staff.find((x) => x.id === id);
const held = useHeld(s, id);
const [pick, setPick] = useState<Held | null>(null);
const [si, setSi] = useState(-1);
const [scan, setScan] = useState(false);
const [err, setErr] = useState("");
const it = pick ? byId[pick.itemId] : undefined;
const stock = useMemo(() => {
if (!it) return [] as number[];
return it.sizes.map((_, i) => onhand(s, L, key(it.id, i)));
}, [it, s, L]);
const onCode = useCallback((raw: string) => {
const k = s.barcodes[raw.trim()];
const hit = held.find((h) => h.key === k);
if (!hit) { setErr(`${raw.trim()} isnt something ${st?.first ?? "they"} is holding.`); return; }
setPick(hit); setSi(-1); setErr("");
}, [s.barcodes, held, st]);
if (!st) return (<><MTop title="Exchange" back /><MRule /><MBody><MEmpty title="No such staff member" /></MBody></>);
const commit = async () => {
if (!pick || si < 0) return;
const r = await mutate<{ size: string }>("issue.exchange", { id: pick.issues[0].id, si, qty: 1 });
if (!r.ok) { setErr(r.error); return; }
router.push(`/m/person/${st.id}`);
};
const willUpdate = it && (isTopItem(it) || isPantItem(it));
return (
<>
<MTop title="Exchange" back right={staffName(st)} />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody>
{!pick ? (
<>
<MSection label="What doesnt fit?" right={`${held.reduce((t, h) => t + h.qty, 0)} out`} />
{held.length === 0
? <MEmpty title="Nothing to exchange" sub={`${staffName(st)} has no garments out at the moment.`} />
: held.map((h) => <MRow key={h.key} onClick={() => { setPick(h); setSi(-1); }} mark="ink" title={h.name} sub={`${h.qty} held`} />)}
{/* Same rule as the return screen: the scan bar is off when nothing is out, so the line
offering a scan goes with it. */}
{held.length > 0 && <div style={{ padding: "18px 16px", fontSize: 14, color: "var(--color-neutral-700)" }}>Or scan the garment theyve brought back.</div>}
</>
) : (
<>
<section style={{ background: INK, color: GROUND, padding: 16 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-300)" }}>Taking back</div>
<div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 8 }}>
<span aria-hidden="true" style={{ width: 4, height: 34, background: "#fff" }} />
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 20, letterSpacing: "-0.02em" }}>{pick.name}</span>
</div>
<button onClick={() => setPick(null)} style={{ marginTop: 12, background: "none", border: 0, padding: 0, color: "#fff", fontSize: 13, fontWeight: 700, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Choose a different garment</button>
</section>
<MSection label="Giving out" right={it ? label(it) : ""} />
<div style={{ padding: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span aria-hidden="true" style={{ width: 4, height: 34, background: "var(--color-accent)" }} />
<span style={{ fontSize: 14, color: "var(--color-neutral-700)" }}>Pick the size that fits. Greyed sizes are the one coming back, or have none on the shelf.</span>
</div>
{it && <MChips sizes={it.sizes.map(String)} value={si} onPick={(i) => setSi(i)} disabled={(i) => i === pick.si || stock[i] <= 0} />}
{si >= 0 && it && (
<p style={{ fontSize: 13.5, color: "var(--color-neutral-700)", marginTop: 14, lineHeight: 1.6 }}>
{stock[si]} on the shelf in size {it.sizes[si]}. The old garment goes back to stock in the same movement
{willUpdate ? `, and ${st.first}s recorded size becomes ${it.sizes[si]}.` : "."}
</p>
)}
</div>
</>
)}
</MBody>
{pick
? <MBar label={busy ? "Recording…" : si >= 0 && it ? `Exchange for size ${it.sizes[si]}` : "Pick a size"} glyph="check" onClick={commit} disabled={busy || si < 0} />
: <MBar label="Scan the garment" glyph="scan" onClick={() => setScan(true)} disabled={held.length === 0} />}
{scan && <MScan title="Scan the garment" onHit={(r) => { onCode(r); setScan(false); }} onClose={() => setScan(false)} />}
</>
);
}
+117
View File
@@ -0,0 +1,117 @@
"use client";
/* Person record — who they are, what they're holding, what has happened, and the three things
you can do about it. Issuing starts here: 1B, person first. */
import Link from "next/link";
import { useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { useSnap } from "@/lib/client";
import { fmtDate, itemMap, staffName, variantName } from "@/lib/compute";
import { GROUND, INK, MBar, MBody, MEmpty, MError, MRow, MRule, MSection, MTop } from "@/components/m";
import { MPersonHead, useHeld } from "@/components/MPerson";
export default function MPersonPage() {
const { s, isAdmin, mutate } = useSnap();
const id = String(useParams().id || "");
const st = s.staff.find((x) => x.id === id);
const held = useHeld(s, id);
const byId = useMemo(() => itemMap(s), [s]);
// Shown once, then gone: the code is a credential and is never in the snapshot.
const [code, setCode] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const origin = typeof window === "undefined" ? "" : window.location.host;
const history = useMemo(() => {
if (!st) return [];
const out: { text: string; date: string }[] = [];
for (const i of s.issues) {
if (i.staffId !== id) continue;
const it = byId[i.itemId];
const size = String(it?.sizes[i.si] ?? i.si);
out.push({ text: `Issued ${i.qty} × ${variantName(it, size)}`, date: i.date });
if (i.returned) out.push({ text: `${i.returned.cond}${variantName(it, size)}`, date: i.returned.date });
if (i.handedIn) out.push({ text: `Handed in — ${variantName(it, size)}`, date: i.handedIn });
}
return out.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)).slice(0, 25);
}, [s, id, st, byId]);
if (!st) return (<><MTop title="Person" back /><MRule /><MBody><MEmpty title="No such staff member" sub="They may have been removed from the register." /></MBody></>);
const total = held.reduce((t, h) => t + h.qty, 0);
/* Issue, Exchange and Return are docked at the foot of the window with nothing underneath them,
so Android draws the gesture handle across their bottom edge. The inset goes inside the bar the
way the shared MBar and MAction take it — same custom property, so an ancestor that zeroes it
for a bar sitting mid-screen would zero this one too — and the accent still runs to the bottom
of the glass while the words stay above the handle. Without it the lower third of "Issue" is
untappable, and this is the row a counter hand hits all day. */
const SAFE_BOTTOM = "var(--tcx-safe-bottom, env(safe-area-inset-bottom, 0px))";
const foot: React.CSSProperties = { flex: 1, minHeight: `calc(64px + ${SAFE_BOTTOM})`, display: "flex", alignItems: "center", padding: `0 16px ${SAFE_BOTTOM}`, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", textDecoration: "none" };
return (
<>
<MTop title="Person" back />
<MRule />
<MBody>
<MPersonHead s={s} st={st} />
<MSection label="Holding now" right={`${total} item${total === 1 ? "" : "s"}`} />
{held.length === 0
? <div style={{ padding: "22px 16px", fontSize: 14, color: "var(--color-neutral-600)" }}>Nothing out at the moment.</div>
: held.map((h) => <MRow key={h.key} title={h.name} right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, fontVariantNumeric: "tabular-nums" }}>{h.qty}</span>} />)}
<MSection label="Their own record" />
{code ? (
<>
<div style={{ padding: "16px" }}>
<div style={{ fontFamily: "ui-monospace, Menlo, Consolas, monospace", fontSize: 26, fontWeight: 800, letterSpacing: "0.06em" }}>{code}</div>
<p style={{ fontSize: 13.5, lineHeight: 1.55, color: "var(--color-neutral-700)", margin: "8px 0 0" }}>
Read this out or write it down now it can&apos;t be shown again. They go to{" "}
<b>{origin}/my</b>, choose &ldquo;I have a code&rdquo;, and set an email and password.
</p>
</div>
<MBar label="Done" tone="ink" glyph="none" onClick={() => setCode(null)} />
</>
) : st.selfEmail ? (
<div style={{ padding: "16px", fontSize: 14, lineHeight: 1.55, color: "var(--color-neutral-600)" }}>
Signed up as {st.selfEmail} they can look up their own record instead of coming to the counter.
</div>
) : (
<>
<div style={{ padding: "16px" }}>
<p style={{ fontSize: 14, lineHeight: 1.55, color: "var(--color-neutral-600)", margin: 0 }}>
{st.selfCode
? "A code is out but hasnt been used. Make a new one if theyve lost it — the old one stops working."
: "Give them a code and they can check what they hold on their own phone. Read-only."}
</p>
</div>
<MError msg={err} onDismiss={() => setErr("")} />
{isAdmin && (
<MBar label={busy ? "Generating…" : st.selfCode ? "New code" : "Generate a code"} tone="ink" glyph="none" disabled={busy}
onClick={async () => {
setBusy(true); setErr("");
const r = await mutate<{ code: string }>("staff.selfCode", { id: st.id });
setBusy(false);
if (!r.ok) { setErr(r.error); return; }
setCode(r.result.code);
}} />
)}
</>
)}
<MSection label="History" />
{history.length === 0
? <div style={{ padding: "22px 16px", fontSize: 14, color: "var(--color-neutral-600)" }}>Nothing recorded for {staffName(st)} yet.</div>
: history.map((h, i) => (
<div key={i} style={{ display: "flex", gap: 12, padding: "14px 16px", borderBottom: "1px solid var(--color-divider)" }}>
<span style={{ flex: 1, fontSize: 14.5 }}>{h.text}</span>
<span style={{ fontSize: 13, color: "var(--color-neutral-600)", whiteSpace: "nowrap" }}>{fmtDate(h.date)}</span>
</div>
))}
</MBody>
<div style={{ display: "flex", flex: `0 0 calc(64px + ${SAFE_BOTTOM})`, borderTop: "2px solid " + INK }}>
<Link href={`/m/issue/${st.id}`} style={{ ...foot, background: "var(--color-accent)", color: "#fff" }}>Issue</Link>
<Link href={`/m/person/${st.id}/exchange`} style={{ ...foot, background: "var(--color-neutral-200)", color: INK, borderLeft: "1px solid " + GROUND }}>Exchange</Link>
<Link href={`/m/person/${st.id}/return`} style={{ ...foot, background: "var(--color-neutral-200)", color: INK, borderLeft: "1px solid " + GROUND }}>Return</Link>
</div>
</>
);
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
/* Return — scan the garment or pick it off what they're holding, say how many and what state
they're in, confirm. The conditions are ThreadCount's real four: only "fit for use" puts a
garment back on the shelf. */
import { useCallback, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSnap } from "@/lib/client";
import { fmtDate, staffName } from "@/lib/compute";
import MScan from "@/components/MScan";
import { INK, MBar, MBody, MEmpty, MError, MRow, MRule, MSection, MStepper, MTop } from "@/components/m";
import { useHeld, type Held } from "@/components/MPerson";
const CONDITIONS: [string, string, string][] = [
["Returned - Good", "Fit for use — back to shelf", "Counts back into stock the moment its confirmed."],
["Returned - Damaged", "Damaged — needs repair", "Stays off the shelf and stays charged to the cost centre."],
["Written Off", "Condemn — beyond repair", "Written off. Nothing comes back to stock."],
["Lost", "Lost", "Never came back. Stays charged."],
];
export default function MReturn() {
const { s, mutate, busy } = useSnap();
const router = useRouter();
const id = String(useParams().id || "");
const st = s.staff.find((x) => x.id === id);
const held = useHeld(s, id);
const [pick, setPick] = useState<Held | null>(null);
// How many of that garment are actually on the counter. Three of a size can be out on one issue
// line, and one pair coming back is one pair — crediting the whole line put two garments that
// are still on a ward back onto the shelf.
const [qty, setQty] = useState(1);
const [cond, setCond] = useState("Returned - Good");
const [scan, setScan] = useState(false);
const [err, setErr] = useState("");
const choose = useCallback((h: Held) => { setPick(h); setQty(h.qty); setErr(""); }, []);
const onCode = useCallback((raw: string) => {
const k = s.barcodes[raw.trim()];
const hit = held.find((h) => h.key === k);
if (!hit) { setErr(`${raw.trim()} isnt something ${st?.first ?? "they"} is holding.`); return; }
choose(hit);
}, [s.barcodes, held, st, choose]);
if (!st) return (<><MTop title="Return" back /><MRule /><MBody><MEmpty title="No such staff member" /></MBody></>);
const confirm = async () => {
if (!pick) return;
// What they hold in this size can be spread over several issue lines, so returning four of
// them is several movements. Oldest line first — the garment that has been out longest is the
// one that came back — and the last line is split when it is only partly returned.
const rows = [...pick.issues].sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
let left = Math.min(qty, pick.qty);
const asked = left;
for (const i of rows) {
if (left <= 0) break;
const take = Math.min(i.qty, left);
const r = await mutate("issue.return", { id: i.id, cond, qty: take });
if (!r.ok) {
// Some of them may already be back. Say so rather than leave the counter to guess, and
// send them back to a fresh list rather than acting on what is now a stale row.
setErr(asked - left > 0 ? `${asked - left} of ${asked} went back before this stopped — ${r.error}` : r.error);
setPick(null);
return;
}
left -= take;
}
router.push(`/m/person/${st.id}`);
};
return (
<>
<MTop title="Return" back right={staffName(st)} />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody>
{pick ? (
<>
<div style={{ padding: "20px 16px", background: "#fff", borderBottom: "2px solid " + INK }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em" }}>{pick.name}</div>
<div style={{ fontSize: 14, color: "var(--color-neutral-600)", marginTop: 6 }}>
Issued to {staffName(st)}, {fmtDate(pick.issues[0].date)}
</div>
<button onClick={() => setPick(null)} style={{ marginTop: 12, background: "none", border: 0, padding: 0, color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Choose a different garment</button>
</div>
<MSection label="How many are coming back?" right={`${pick.qty} out`} />
<div style={{ display: "flex", alignItems: "center", gap: 16, padding: 16, borderBottom: "1px solid var(--color-divider)" }}>
<span style={{ flex: 1, fontSize: 14, color: "var(--color-neutral-700)", lineHeight: 1.55 }}>
{pick.qty === 1
? "One is out, so this is it."
: `${pick.qty} are out. Count what is on the counter — the rest stays on ${st.first}s record.`}
</span>
<MStepper n={qty} onChange={setQty} min={1} max={pick.qty} />
</div>
<MSection label="Condition" />
<div style={{ padding: 16, display: "grid", gap: 8 }}>
{CONDITIONS.map(([value, title, note]) => {
const on = cond === value;
return (
<button key={value} onClick={() => setCond(value)} aria-pressed={on}
style={{ textAlign: "left", padding: "16px 18px", minHeight: 64, border: "2px solid " + INK, background: on ? INK : "transparent", color: on ? "var(--color-bg)" : INK, cursor: "pointer" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 17, letterSpacing: "-0.01em" }}>{title}</div>
<div style={{ fontSize: 13, marginTop: 4, color: on ? "var(--color-neutral-400)" : "var(--color-neutral-600)" }}>{note}</div>
</button>
);
})}
</div>
</>
) : (
<>
<MSection label="What is coming back?" right={`${held.reduce((t, h) => t + h.qty, 0)} out`} />
{held.length === 0
? <MEmpty title="Nothing to return" sub={`${staffName(st)} has no garments out at the moment.`} />
: held.map((h) => <MRow key={h.key} onClick={() => choose(h)} mark="ink" title={h.name} sub={`${h.qty} held · issued ${fmtDate(h.issues[0].date)}`} />)}
{/* A return has to match a record they hold, which is why the scan bar below is off when
nothing is out — so don't invite a scan the bar then refuses. */}
{held.length > 0 && <div style={{ padding: "18px 16px", fontSize: 14, color: "var(--color-neutral-700)" }}>Or scan the garment.</div>}
</>
)}
</MBody>
{pick
? <MBar label={busy ? "Recording…" : qty === 1 ? "Confirm return" : `Confirm return of ${qty}`} glyph="check" onClick={confirm} disabled={busy} />
: <MBar label="Scan the garment" glyph="scan" onClick={() => setScan(true)} disabled={held.length === 0} />}
{scan && <MScan title="Scan the garment" onHit={(r) => { onCode(r); setScan(false); }} onClose={() => setScan(false)} />}
</>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
/* The call list — longest wait first, because thats the one somebody is annoyed about. */
import { useMemo, useState } from "react";
import { useSnap } from "@/lib/client";
import { daysBetween, itemMap, label, staffMap, staffName } from "@/lib/compute";
import { INK, MBody, MEmpty, MError, MNav, MRow, MRule, MSection, MTop } from "@/components/m";
export default function MPickups() {
const { s, mutate, busy } = useSnap();
const [err, setErr] = useState("");
const rows = useMemo(() => {
const staffById = staffMap(s);
const byId = itemMap(s);
return s.pickups
.filter((p) => !p.pickedUp)
.map((p) => ({
...p,
who: staffById[p.staffId],
phone: staffById[p.staffId]?.phone || "",
days: daysBetween(p.received, s.today),
what: p.lines.map((l) => `${label(byId[l.itemId])} · ${l.size}${l.qty > 1 ? ` ×${l.qty}` : ""}`).join(", "),
}))
.sort((a, b) => b.days - a.days);
}, [s]);
const act = async (op: string, id: string) => {
const r = await mutate(op, { id });
if (!r.ok) setErr(r.error);
};
return (
<>
<MTop title="Pickups" back right={rows.length ? `${rows.length} waiting` : undefined} />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody>
{rows.length === 0 ? (
<MEmpty title="Nobody is waiting" sub="Everything that has come in has been collected." />
) : (
<>
<MSection label="Waiting" right="Longest first" />
{rows.map((p) => (
<div key={p.id} style={{ padding: 16, background: p.days > 10 ? "#fff" : "var(--color-bg)", borderBottom: "1px solid var(--color-divider)", display: "flex", gap: 12 }}>
<span aria-hidden="true" style={{ width: 4, flex: "0 0 4px", background: p.days > 10 ? "var(--color-accent)" : INK, alignSelf: "stretch" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
<span style={{ flex: 1, fontSize: 16.5, fontWeight: 700 }}>{staffName(p.who, "Staff member")}</span>
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, color: p.days > 10 ? "var(--color-accent-700)" : "var(--color-neutral-600)", fontVariantNumeric: "tabular-nums" }}>{p.days}d</span>
</div>
<div style={{ fontSize: 13.5, color: "var(--color-neutral-600)", marginTop: 3 }}>{p.what} · {p.orderCode}</div>
<div style={{ display: "flex", gap: 8, marginTop: 12, flexWrap: "wrap" }}>
{p.phone && (
<a href={`tel:${p.phone.replace(/\s+/g, "")}`}
style={{ minHeight: 44, display: "inline-flex", alignItems: "center", padding: "0 14px", border: "2px solid " + INK, color: INK, textDecoration: "none", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase" }}>Call</a>
)}
<button onClick={() => act("pickup.contacted", p.id)} disabled={busy || p.contacted}
style={{ minHeight: 44, padding: "0 14px", border: "2px solid " + INK, background: p.contacted ? INK : "transparent", color: p.contacted ? "var(--color-bg)" : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: p.contacted ? "default" : "pointer", opacity: busy ? 0.5 : 1 }}>
{p.contacted ? "Contacted" : "Mark contacted"}
</button>
<button onClick={() => act("pickup.pickedUp", p.id)} disabled={busy}
style={{ minHeight: 44, padding: "0 14px", border: "2px solid var(--color-accent)", background: "var(--color-accent)", color: "#fff", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer", opacity: busy ? 0.5 : 1 }}>
Collected
</button>
</div>
</div>
</div>
))}
</>
)}
</MBody>
<MNav />
</>
);
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
/* Receive a delivery — tick lines against the invoice as you unpack. Receiving closes the order:
anything short is raised as its own back order, so the shortfall is chased on a live order
rather than left sitting on a closed one. */
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useSnap } from "@/lib/client";
import { fmtDate, itemMap, label, OPEN_STATUSES, sizeIndexOf, staffMap, staffName, variantName } from "@/lib/compute";
import { INK, MBar, MBody, MEmpty, MError, MRow, MRule, MSection, MStepper, MTop } from "@/components/m";
export default function MReceive() {
const { s, mutate, busy } = useSnap();
const router = useRouter();
const [pick, setPick] = useState<string | null>(null);
const [got, setGot] = useState<Record<string, number>>({});
const [invoice, setInvoice] = useState("");
const [err, setErr] = useState("");
/* The shortfall is banked here at the moment of receipt, not read off the order afterwards:
receiving closes the order, so the refresh that follows drops it out of the open list and both
`lines` and `short` fall empty. Reading them on this screen told a storeperson who had just
stepped two tunics down to zero that everything on the order arrived, and the back order sat
unchased on Ordering. */
const [done, setDone] = useState<{ code: string; short: number } | null>(null);
const byId = useMemo(() => itemMap(s), [s]);
const staffById = useMemo(() => staffMap(s), [s]);
const open = useMemo(() => s.orders.filter((o) => OPEN_STATUSES.includes(o.status) && o.status !== "Draft"), [s]);
const order = open.find((o) => o.id === pick);
// What's still outstanding on each line after any earlier partial receipt.
const lines = useMemo(() => {
if (!order) return [];
return order.lines.map((l) => {
const already = order.receipts.reduce((t, r) => t + r.lines.filter((x) => x.itemId === l.itemId && x.size === l.size).reduce((a, x) => a + x.qty, 0), 0);
return { ...l, already, outstanding: Math.max(0, l.qty - already), name: `${variantName(byId[l.itemId], l.size)}`, ok: sizeIndexOf(byId[l.itemId], l.size) >= 0 };
}).filter((l) => l.outstanding > 0);
}, [order, byId]);
const q = (id: string, fallback: number) => got[id] ?? fallback;
const arriving = lines.reduce((t, l) => t + q(l.id, l.outstanding), 0);
const short = lines.filter((l) => q(l.id, l.outstanding) < l.outstanding);
const receive = async () => {
if (!order) return;
const r = await mutate("order.receive", {
id: order.id, invoice: invoice.trim(),
lines: lines.map((l) => ({ lineId: l.id, itemId: l.itemId, size: l.size, arrived: q(l.id, l.outstanding), dest: order.staffId ? "pickup" : "shelf" })),
});
if (!r.ok) { setErr(r.error); return; }
setDone({ code: order.code, short: short.length });
};
if (done) return (
<>
<MTop title="Received" />
<MRule />
<MBody><MEmpty title={`${done.code} received`} sub={done.short ? `${done.code} is closed as received, and a back order for the ${done.short} short line${done.short === 1 ? "" : "s"} has been raised automatically. Its waiting on Ordering on the desktop.` : "Everything on the order arrived. Stock is updated."} /></MBody>
<MBar label="Back" onClick={() => router.push("/m/more")} />
</>
);
return (
<>
<MTop title="Receive" back right={order ? order.code : `${open.length} on order`} />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody>
{!order ? (
<>
<MSection label="On their way" />
{open.length === 0
? <MEmpty title="Nothing on order" sub="Orders show up here once theyre marked as ordered on the desktop." />
: open.map((o) => (
<MRow key={o.id} onClick={() => { setPick(o.id); setGot({}); setInvoice(o.invoice || ""); }} mark="ink"
title={`${o.supplier || "Supplier"} · ${o.code}`}
sub={[o.staffId ? `For ${staffName(staffById[o.staffId])}` : "For stock", o.expected ? `expected ${fmtDate(o.expected)}` : o.status].filter(Boolean).join(" · ")}
right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 17, fontVariantNumeric: "tabular-nums" }}>{o.lines.reduce((t, l) => t + l.qty, 0)}</span>} />
))}
</>
) : (
<>
<div style={{ padding: "20px 16px", background: "#fff", borderBottom: "2px solid " + INK }}>
<div style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-700)" }}>{order.supplier || "Supplier"}</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em", marginTop: 6 }}>{order.code}</div>
<div style={{ fontSize: 13.5, color: "var(--color-neutral-600)", marginTop: 6 }}>
Ordered {fmtDate(order.date)}{order.expected ? ` · expected ${fmtDate(order.expected)}` : ""}
</div>
<input value={invoice} onChange={(e) => setInvoice(e.target.value)} placeholder="Invoice number (optional)" aria-label="Invoice number"
style={{ width: "100%", minHeight: 48, padding: "10px 12px", border: "2px solid " + INK, background: "var(--color-bg)", fontSize: 16, fontWeight: 600, marginTop: 14 }} />
<button onClick={() => setPick(null)} style={{ marginTop: 12, background: "none", border: 0, padding: 0, color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Choose a different order</button>
</div>
<MSection label="Tick each line as you unpack" right="Arrived / ordered" />
{lines.length === 0
? <MEmpty title="Nothing outstanding" sub="Every line on this order has already been receipted." />
: lines.map((l) => (
<div key={l.id} style={{ display: "flex", alignItems: "center", gap: 12, padding: 16, borderBottom: "1px solid var(--color-divider)", background: q(l.id, l.outstanding) < l.outstanding ? "#fff" : "var(--color-bg)" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700 }}>{l.name}</div>
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 3 }}>
{l.outstanding} outstanding{l.already ? ` · ${l.already} already received` : ""}
{q(l.id, l.outstanding) < l.outstanding ? ` · ${l.outstanding - q(l.id, l.outstanding)} short` : ""}
</div>
</div>
<MStepper n={q(l.id, l.outstanding)} onChange={(n) => setGot((x) => ({ ...x, [l.id]: n }))} max={l.outstanding} />
</div>
))}
{lines.length > 0 && (
<p style={{ padding: "18px 16px 26px", fontSize: 14, color: "var(--color-neutral-700)", lineHeight: 1.6 }}>
{order.staffId ? "Goes onto the pickup list for the staff member it was ordered for." : "Goes onto the shelf."}
{short.length > 0 && ` ${short.length} line${short.length === 1 ? "" : "s"} short — ${order.code} closes as received and the shortfall goes onto a new back order.`}
</p>
)}
</>
)}
</MBody>
{order && lines.length > 0 && <MBar label={busy ? "Receiving…" : `Receive ${arriving} item${arriving === 1 ? "" : "s"}`} glyph="check" onClick={receive} disabled={busy || arriving === 0} />}
</>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
/* Reorder draft what fell below par, at quantities that bring each line back up. Adjust and raise.
This raises a draft on Ordering; nothing reaches a supplier until someone approves it there. */
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { flaggedNeeds, label, onhand, reorderAt, touched, variantName } from "@/lib/compute";
import { INK, MBar, MBody, MEmpty, MError, MRule, MStepper, MTop } from "@/components/m";
export default function MReorder() {
const { s, mutate, busy } = useSnap();
const { L, byId, variants } = useDerived();
const router = useRouter();
const [qty, setQty] = useState<Record<string, number>>({});
const [err, setErr] = useState("");
const [done, setDone] = useState<string | null>(null);
const needs = useMemo(() => flaggedNeeds(s, L, byId).map((n) => {
const k = `${n.itemId}:${n.si}`;
return { ...n, key: k, name: `${variantName(byId[n.itemId], n.size)}`, oh: onhand(s, L, k), par: reorderAt(s, k) };
}), [s, L, byId]);
// /m/stock's "N below par" counts every line at or under its reorder point. flaggedNeeds nets off
// what is already on an open order and drops a line once that covers it, so the two figures
// legitimately differ — and a counter who taps "Reorder 8 lines" and is told nothing needs
// ordering stops believing the screen. Report both, and never call a short shelf healthy:
// stock on order is not stock on the shelf until somebody receipts it.
const belowPar = useMemo(
() => variants.filter((v) => touched(s, L, v.key) && onhand(s, L, v.key) <= reorderAt(s, v.key)).length,
[s, L, variants],
);
const covered = belowPar - needs.length;
const q = (k: string, fallback: number) => qty[k] ?? fallback;
const total = needs.reduce((t, n) => t + q(n.key, n.qty), 0);
const suppliers = [...new Set(needs.map((n) => n.supplier))];
const raise = async () => {
const bySup: Record<string, typeof needs> = {};
for (const n of needs) if (q(n.key, n.qty) > 0) (bySup[n.supplier] ||= []).push(n);
const codes: string[] = [];
for (const sup of Object.keys(bySup)) {
const r = await mutate<{ code: string }>("order.create", {
orderFor: "Stock", supplier: sup, replenish: false, notes: "Raised from a stocktake on the app",
lines: bySup[sup].map((n) => ({ itemId: n.itemId, size: n.size, qty: q(n.key, n.qty) })),
});
if (!r.ok) { setErr(r.error); return; }
codes.push(r.result.code);
}
setDone(codes.join(" · "));
};
if (done) return (
<>
<MTop title="Reorder" />
<MRule />
<MBody><MEmpty title={`Draft ${done} raised`} sub="Its waiting on Ordering. Check the quantities and the supplier reference there, then send it." /></MBody>
<MBar label="Back to stock" href="/m/stock" />
</>
);
return (
<>
<MTop title="Reorder" back right={needs.length ? `${needs.length} line${needs.length === 1 ? "" : "s"}` : undefined} />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody>
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
<div style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-700)" }}>Below par</div>
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, letterSpacing: "-0.03em", lineHeight: 1.05, marginTop: 8 }}>Draft order</h2>
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 10, lineHeight: 1.6 }}>
{needs.length === 0
? belowPar === 0
? "Every line is at or above its par level. Nothing needs ordering."
: `${belowPar} line${belowPar === 1 ? "" : "s"} ${belowPar === 1 ? "is" : "are"} at or below par, and open orders already cover ${belowPar === 1 ? "it" : "them"}. Theres nothing more to raise — but the shelf stays short until the delivery is receipted.`
: covered > 0
? `${belowPar} lines are at or below par. Open orders cover ${covered}; the other ${needs.length} still need${needs.length === 1 ? "s" : ""} ordering, at quantities that bring ${needs.length === 1 ? "it" : "each one"} back up.`
: `${needs.length} line${needs.length === 1 ? "" : "s"} ${needs.length === 1 ? "is" : "are"} at or below par. Quantities bring each one back up.`}
</p>
</div>
{needs.length === 0 ? (
<MEmpty
title={belowPar ? "Already on order" : "Nothing to reorder"}
sub={belowPar
? "Every short line is on an open order. Receipt it on Ordering when it lands — until then those shelves are still short."
: "Come back after a count, or lower a par level on the desktop if a line should be carrying more."} />
) : needs.map((n) => (
<div key={n.key} style={{ display: "flex", alignItems: "center", gap: 12, padding: 16, background: "#fff", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16.5, fontWeight: 700, letterSpacing: "-0.01em" }}>{n.name}</div>
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 3 }}>{n.oh} on hand · par {n.par} · {n.supplier}</div>
</div>
<MStepper n={q(n.key, n.qty)} onChange={(v) => setQty((x) => ({ ...x, [n.key]: v }))} />
</div>
))}
{needs.length > 0 && (
<p style={{ padding: "18px 16px 26px", fontSize: 14, color: "var(--color-neutral-700)", lineHeight: 1.6 }}>
Goes to {suppliers.length === 1 ? suppliers[0] : `${suppliers.length} suppliers`} as a draft. Nothing is sent until you approve it on Ordering.
</p>
)}
</MBody>
{needs.length > 0 && <MBar label={busy ? "Raising…" : `Raise the draft — ${total} item${total === 1 ? "" : "s"}`} onClick={raise} disabled={busy || total === 0} />}
</>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
/* Delivery round everything waiting, grouped by ward, handed over on the floor with a signature.
Reuses the same signature pad and photo upload as the desktop, so a handover looks identical
in the record whichever screen recorded it. */
import { useMemo, useRef, useState } from "react";
import { useSnap } from "@/lib/client";
import { daysBetween, itemMap, label, staffMap, staffName, type PickupRec } from "@/lib/compute";
import { uploadPhoto } from "@/lib/photo";
import { SignaturePad } from "@/components/dialogs";
import { INK, MBar, MBody, MEmpty, MError, MNav, MRow, MRule, MSection, MTop, inputStyle } from "@/components/m";
export default function MRounds() {
const { s, mutate, busy } = useSnap();
const [pick, setPick] = useState<PickupRec | null>(null);
const [name, setName] = useState("");
const [err, setErr] = useState("");
const [saving, setSaving] = useState(false);
const pad = useRef<{ clear: () => void; dataUrl: () => string | null } | null>(null);
const byId = useMemo(() => itemMap(s), [s]);
const staffById = useMemo(() => staffMap(s), [s]);
/* Grouped by ward — a round is walked ward by ward, not order by order. */
const wards = useMemo(() => {
const m: Record<string, PickupRec[]> = {};
for (const p of s.pickups) {
if (p.pickedUp) continue;
const w = staffById[p.staffId]?.dept || "No ward recorded";
(m[w] ||= []).push(p);
}
return Object.entries(m).sort((a, b) => a[0].localeCompare(b[0]));
}, [s, staffById]);
const items = (p: PickupRec) => p.lines.map((l) => `${label(byId[l.itemId])} ${l.size}${l.qty > 1 ? ` ×${l.qty}` : ""}`).join(", ");
const deliver = async () => {
if (!pick || saving) return;
setSaving(true); setErr("");
let sigId: string | null = null;
const png = pad.current?.dataUrl() || null;
if (png) {
const up = await uploadPhoto(mutate, "sig", png);
if ("error" in up) { setSaving(false); setErr(up.error); return; }
sigId = up.id;
}
const r = await mutate("pickup.deliver", { id: pick.id, deliveredTo: name.trim(), sigId });
setSaving(false);
if (!r.ok) { setErr(r.error); return; }
setPick(null); setName("");
};
const total = wards.reduce((t, [, ps]) => t + ps.length, 0);
if (pick) {
const st = staffById[pick.staffId];
return (
<>
<MTop title="Hand over" back onBack={() => setPick(null)} right={st?.dept || undefined} />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody>
<section style={{ background: INK, color: "var(--color-bg)", padding: "18px 16px" }}>
<div style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-300)" }}>{pick.orderCode}</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 26, letterSpacing: "-0.02em", marginTop: 8 }}>{staffName(st, "Staff member")}</div>
<div style={{ fontSize: 13.5, color: "var(--color-neutral-400)", marginTop: 8 }}>{items(pick)}</div>
</section>
<MSection label="Received by" />
<div style={{ padding: 16 }}>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name of whoever signs, e.g. the manager" aria-label="Received by" style={inputStyle} />
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginTop: 18, marginBottom: 8 }}>Signature</div>
<SignaturePad onReady={(api) => { pad.current = api; }} />
<button onClick={() => pad.current?.clear()} style={{ marginTop: 10, background: "none", border: 0, padding: 0, color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Clear the signature</button>
<p style={{ fontSize: 13.5, color: "var(--color-neutral-700)", marginTop: 18, lineHeight: 1.6 }}>
Handing over records the garments as collected the same as a pickup at the counter and keeps the name and signature with the record.
</p>
</div>
</MBody>
<MBar label={saving ? "Recording…" : "Delivered"} glyph="check" onClick={deliver} disabled={saving || busy} />
</>
);
}
return (
<>
<MTop title="Round" back right={total ? `${total} to drop off` : undefined} />
<MRule />
<MBody>
{total === 0 ? (
<MEmpty title="Nothing to deliver" sub="Everything that has come in has been collected or handed over." />
) : wards.map(([ward, ps]) => (
<div key={ward}>
<MSection label={ward} right={`${ps.length} order${ps.length === 1 ? "" : "s"}`} />
{ps.map((p) => {
const st = staffById[p.staffId];
const days = daysBetween(p.received, s.today);
return (
<MRow key={p.id} onClick={() => { setPick(p); setName(""); }} mark={days > 10 ? "accent" : "ink"} attention={days > 10}
title={staffName(st, "Staff member")}
sub={`${items(p)} · waiting ${days}d`}
right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase" }}>Sign</span>} />
);
})}
</div>
))}
</MBody>
<MNav />
</>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
/* One field over people and stock together at the counter you don't know in advance which one
you're after. People take an accent marker, stock lines an ink one. */
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { bcBound, ccOf, label, locMap, locTrail, onhand, touched, reorderAt, staffName, variantName } from "@/lib/compute";
import MScan from "@/components/MScan";
import { INK, IconScan, MBody, MEmpty, MNav, MRow, MRule, MSection, MTop, inputStyle } from "@/components/m";
export default function MSearch() {
const { s } = useSnap();
const { L, byId, variants } = useDerived();
const [q, setQ] = useState("");
const [scan, setScan] = useState(false);
const locs = useMemo(() => locMap(s), [s]);
const { people, lines } = useMemo(() => {
const needle = q.trim().toLowerCase();
const stockRows = variants.filter((v) => touched(s, L, v.key)).map((v) => ({
// Only the bound supplier code: it is what someone reads off a label and types in here.
...v, oh: onhand(s, L, v.key), par: reorderAt(s, v.key), code: bcBound(s, v.item, v.si),
where: locTrail(locs, s.placed[v.key], 0), name: `${variantName(byId[v.itemId], v.size)}`,
}));
if (!needle) {
const seen: Record<string, string> = {};
for (const i of s.issues) seen[i.staffId] = i.date > (seen[i.staffId] || "") ? i.date : seen[i.staffId];
return {
people: s.staff.filter((x) => !x.inactive).sort((a, b) => (seen[b.id] || "").localeCompare(seen[a.id] || "")).slice(0, 5),
lines: stockRows.filter((r) => r.oh <= r.par).slice(0, 5),
};
}
return {
people: s.staff.filter((x) => !x.inactive && `${x.first} ${x.last} ${x.num} ${x.dept}`.toLowerCase().includes(needle)).slice(0, 12),
lines: stockRows.filter((r) => `${r.name} ${r.code} ${r.where}`.toLowerCase().includes(needle)).slice(0, 20),
};
}, [s, L, variants, byId, q, locs]);
const empty = q.trim() && people.length === 0 && lines.length === 0;
return (
<>
<MTop title="Search" right={q.trim() ? `${people.length + lines.length} result${people.length + lines.length === 1 ? "" : "s"}` : undefined} />
<MRule />
<MBody>
<div style={{ padding: 16, borderBottom: "2px solid " + INK, display: "flex", gap: 8 }}>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name, staff number, garment or code" autoFocus
aria-label="Search people and stock" style={{ ...inputStyle, flex: 1 }} />
<button onClick={() => setScan(true)} aria-label="Scan a barcode"
style={{ width: 56, minHeight: 48, border: "2px solid " + INK, background: "var(--color-accent)", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer" }}>
<IconScan />
</button>
</div>
{empty ? (
<MEmpty title="Nothing matches that" sub="Try a surname, a staff number, or part of a garment name. Scanning a label finds it straight away." />
) : (
<>
{people.length > 0 && (
<>
<MSection label={q.trim() ? "People" : "Recently served"} />
{people.map((st) => (
<MRow key={st.id} href={`/m/person/${st.id}`} mark="accent" title={staffName(st)}
sub={[st.num, st.dept || st.group, ccOf(s, st) && `CC ${ccOf(s, st)}`].filter(Boolean).join(" · ")} />
))}
</>
)}
{lines.length > 0 && (
<>
<MSection label={q.trim() ? "Stock" : "At or below par"} right="On hand / par" />
{lines.map((r) => (
<MRow key={r.key} href="/m/stock" mark="ink" attention={r.oh <= r.par} title={r.name}
sub={[r.code || "No barcode bound", r.where].filter(Boolean).join(" · ")}
right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, fontVariantNumeric: "tabular-nums", color: r.oh <= r.par ? "var(--color-accent-700)" : INK }}>{r.oh}<span style={{ color: "var(--color-neutral-700)" }}>/{r.par}</span></span>} />
))}
</>
)}
</>
)}
</MBody>
<MNav />
{scan && <MScan title="Scan to find" onHit={(raw) => {
const k = s.barcodes[raw.trim()];
const v = k ? variants.find((x) => x.key === k) : undefined;
setQ(v ? `${variantName(byId[v.itemId], v.size)}` : raw.trim());
setScan(false);
}} onClose={() => setScan(false)} />}
</>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
/* Settings only what the app itself controls. Everything else about the facility lives on the
desktop, so there is one place a setting can be wrong rather than two. */
import { DELETE_ACCOUNT_URL, PRIVACY_URL, TERMS_URL } from "@/lib/links";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useSnap } from "@/lib/client";
import { locTree } from "@/lib/compute";
import { clearAllCounts } from "@/lib/opencount";
import { INK, MBody, MError, MRow, MRule, MSection, MStepper, MTop } from "@/components/m";
const BEEP_KEY = "tc.beep";
export default function MSettings() {
const { s, isAdmin, mutate, busy } = useSnap();
const router = useRouter();
const [beep, setBeep] = useState(true);
const [gate, setGate] = useState(s.settings.varianceReason);
const [err, setErr] = useState("");
useEffect(() => { try { setBeep(localStorage.getItem(BEEP_KEY) !== "0"); } catch { /* blocked store */ } }, []);
const toggleBeep = () => {
const next = !beep;
setBeep(next);
try { localStorage.setItem(BEEP_KEY, next ? "1" : "0"); } catch { /* blocked store */ }
};
const saveGate = async (n: number) => {
setGate(n);
const r = await mutate("settings.update", { varianceReason: n });
if (!r.ok) { setErr(r.error); setGate(s.settings.varianceReason); }
};
const signOut = async () => {
// Their part-counted shelves go with them. The tally is keyed per person, so what is left
// behind can never be read by the next signed-in user — but it is theirs, it is on a phone
// that is passed around a linen room, and nothing would ever clear it again once they have
// gone. Done before the logout POST so a failed request still leaves the device tidy.
clearAllCounts(s.session.userId);
await fetch("/api/auth/logout", { method: "POST" });
// /m/login, not /auth: /auth is the website's two-pane desktop sign-in, and landing on it
// inside a phone app is how you make someone think the app is broken. A full navigation
// rather than router.push, because the session cookie has just been cleared and every page
// behind it is server-rendered.
window.location.replace("/m/login");
};
const locs = locTree(s).length;
return (
<>
<MTop title="Settings" back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody>
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 26, letterSpacing: "-0.03em" }}>{s.session.name}</div>
<div style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 6 }}>
{[s.session.title || s.session.role, s.session.email].filter(Boolean).join(" · ")}
</div>
</div>
<MSection label="Site" />
<MRow title={s.settings.facility} sub={s.settings.location} right={<span style={{ fontSize: 13, color: "var(--color-neutral-600)" }}>Desktop</span>} />
<MRow title="Locations" sub={locs ? `${locs} ${locs === 1 ? "shelf" : "shelves"} and bays set up` : "None set up yet"} right={<span style={{ fontSize: 13, color: "var(--color-neutral-600)" }}>Desktop</span>} />
<MSection label="Counting" />
<MRow title="Beep and buzz on a scan" sub="This device only"
right={
<button onClick={toggleBeep} role="switch" aria-checked={beep} aria-label="Beep and buzz on a scan"
style={{ minWidth: 72, minHeight: 44, border: "2px solid " + INK, background: beep ? INK : "transparent", color: beep ? "var(--color-bg)" : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>
{beep ? "On" : "Off"}
</button>
} />
<MRow title="Reason required at" sub={isAdmin ? "A count gap this big has to say why" : "Set by an administrator"}
right={isAdmin
? <MStepper n={gate} onChange={saveGate} min={1} max={99} />
: <span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19 }}>{gate}</span>} />
{/* Deleting an account has to be reachable from inside the app, not only from a web page
somebody has to know exists this is the app that created the facility in the first
place, and it is a Play requirement besides. These rows go to the site's own pages
rather than a second deletion screen: there is one account-deletion flow, and it is
the one on the website.
Absolute, and on the web they open in a new tab. On a phone they cannot: this shell
registers no browser plugin, so nothing here is able to hand a URL to Chrome, and the
page loads over the top of the counter with the site's own nav and no tab bar. The row
says as much before the tap, and the hardware back button comes straight back. The other
two ways out were worse: a row that does nothing when tapped, or no deletion route in
the app at all. Somebody who signs in rather than signing up never passes the links on
the create-account screen, so this is the only place the signed-in counter app names
them at all. */}
{(DELETE_ACCOUNT_URL || PRIVACY_URL || TERMS_URL) && <MSection label="Your account and your data" />}
{DELETE_ACCOUNT_URL && <MRow href={DELETE_ACCOUNT_URL} external mark="ink" title="Delete your account" sub="How to do it, and exactly what goes with it" />}
{PRIVACY_URL && <MRow href={PRIVACY_URL} external mark="ink" title="Privacy policy" sub="What ThreadCount stores, and what it never does" />}
{TERMS_URL && <MRow href={TERMS_URL} external mark="ink" title="Terms of use" sub="What you and ThreadCount each agree to" />}
<div style={{ padding: 16 }}>
<button onClick={signOut} disabled={busy}
style={{ width: "100%", minHeight: 64, border: "2px solid var(--color-accent)", background: "transparent", color: "var(--color-accent-700)", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase", textAlign: "left", padding: "0 20px", cursor: "pointer" }}>
Sign out
</button>
</div>
<p style={{ padding: "0 16px 26px", fontSize: 13.5, color: "var(--color-neutral-600)", lineHeight: 1.6 }}>
Ordering, reports, the catalogue and the staff register are all on the desktop site.
</p>
</MBody>
</>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
/* Signed in onboarding screen 05. A beat of confirmation before the app: which linen room you
are now in, and the two figures that decide what the morning looks like. */
import { Suspense, useMemo } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { daysBetween, locTree, onhand, reorderAt, touched } from "@/lib/compute";
function SignedInInner() {
const { s } = useSnap();
const { L, variants } = useDerived();
const isNew = useSearchParams().get("new") === "1";
const d = useMemo(() => {
const counted = variants.filter((v) => touched(s, L, v.key));
const low = counted.filter((v) => onhand(s, L, v.key) <= reorderAt(s, v.key)).length;
const lastCount = s.stocktakes.find((t) => t.mode !== "preloved");
return {
lines: counted.length,
low,
since: lastCount ? daysBetween(lastCount.date, s.today) : null,
locations: locTree(s).length,
};
}, [s, L, variants]);
const row: React.CSSProperties = { display: "flex", alignItems: "baseline", gap: 12, padding: "14px 0", borderBottom: "1px solid var(--color-divider)" };
const lab: React.CSSProperties = { flex: 1, fontSize: 14.5, color: "var(--color-neutral-800)" };
// Figures get the big numeral; "Never counted" is a sentence and was being set at the same size,
// where it ran nearly the width of the row and read as the loudest thing on the screen.
const val = (hot?: boolean, text?: boolean): React.CSSProperties => ({ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: text ? 14.5 : 19, fontVariantNumeric: "tabular-nums", color: hot ? "var(--color-accent-700)" : "var(--color-text)" });
const overdue = d.since !== null && d.since > 30;
return (
<>
<section style={{ background: "var(--color-accent)", color: "#fff", padding: "calc(34px + env(safe-area-inset-top, 0px)) 24px 34px" }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.14em", textTransform: "uppercase", color: "rgba(255,255,255,0.88)" }}>
{isNew ? "Facility created" : "Signed in"}
</div>
<h1 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 36, lineHeight: 1.02, letterSpacing: "-0.03em", marginTop: 10 }}>
{s.settings.facility}
</h1>
</section>
<div style={{ flex: 1, overflowY: "auto", padding: "24px 24px 30px", background: "var(--color-bg)" }}>
{isNew ? (
<p style={{ fontSize: 15, lineHeight: 1.6, color: "var(--color-neutral-800)" }}>
Your linen room is set up and you are its first administrator. There is nothing in it
yet the catalogue, staff register and cost centres come in from CSV on the desktop
site, and take about an afternoon.
</p>
) : (
<>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>
{s.settings.location || "Linen Room"}
</div>
<div style={{ marginTop: 14, borderTop: "2px solid var(--color-text)" }}>
<div style={row}><span style={lab}>Lines on the shelf</span><span style={val()}>{d.lines}</span></div>
<div style={row}><span style={lab}>Below par</span><span style={val(d.low > 0)}>{d.low}</span></div>
<div style={row}>
<span style={lab}>Since the last count</span>
<span style={val(overdue, d.since === null)}>{d.since === null ? "Never counted" : `${d.since} day${d.since === 1 ? "" : "s"}`}</span>
</div>
</div>
{d.locations === 0 && (
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--color-neutral-800)", background: "#fff", borderLeft: "6px solid var(--color-accent)", padding: 14, marginTop: 20 }}>
No shelves set up yet, so there is nothing to count against. Add them in Settings on
the desktop, then place each size on one from Inventory.
</p>
)}
</>
)}
</div>
<Link href="/m" replace
style={{ flex: "0 0 auto", height: 66, background: "var(--color-text)", color: "var(--color-bg)", display: "flex", alignItems: "center", gap: 12, padding: "0 24px calc(0px + env(safe-area-inset-bottom, 0px))", textDecoration: "none", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase" }}>
<span style={{ flex: 1 }}>Start the day</span>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="square" aria-hidden="true"><path d="M5 12h14" /><path d="m13 6 6 6-6 6" /></svg>
</Link>
</>
);
}
export default function MSignedIn() {
return <Suspense fallback={null}><SignedInInner /></Suspense>;
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
/* Stock — on hand against par, worst first, with the three things you do about it underneath. */
import Link from "next/link";
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { bcBound, label, locMap, locTrail, onhand, touched, reorderAt, variantName } from "@/lib/compute";
import { INK, IconRight, MBody, MEmpty, MNav, MRow, MRule, MSection, MTop, inputStyle } from "@/components/m";
export default function MStock() {
const { s } = useSnap();
const { L, byId, variants } = useDerived();
const [q, setQ] = useState("");
const locs = useMemo(() => locMap(s), [s]);
const rows = useMemo(() => {
const needle = q.trim().toLowerCase();
return variants
.filter((v) => touched(s, L, v.key))
.map((v) => {
const oh = onhand(s, L, v.key), par = reorderAt(s, v.key);
// The bound supplier code, not bcFor()'s generated stand-in: this line is read against a
// label on a garment, and a number printed on nothing is worse than saying there isn't one.
return { ...v, oh, par, low: oh <= par, code: bcBound(s, v.item, v.si), where: locTrail(locs, s.placed[v.key], 0), name: `${variantName(byId[v.itemId], v.size)}` };
})
.filter((r) => !needle || `${r.name} ${r.code} ${r.where}`.toLowerCase().includes(needle))
// Short lines first, then furthest below par — the shelf you have to do something about.
.sort((a, b) => Number(b.low) - Number(a.low) || (a.oh - a.par) - (b.oh - b.par) || a.name.localeCompare(b.name));
}, [s, L, variants, byId, q, locs]);
const low = rows.filter((r) => r.low).length;
const link: React.CSSProperties = { display: "flex", alignItems: "center", gap: 12, minHeight: 64, padding: "0 20px", border: "2px solid " + INK, color: INK, textDecoration: "none", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase" };
return (
<>
<MTop title="Stock" right={low ? `${low} below par` : `${rows.length} line${rows.length === 1 ? "" : "s"}`} />
<MRule />
<MBody>
<div style={{ padding: 16, borderBottom: "2px solid " + INK }}>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Garment, code or shelf" aria-label="Filter stock" style={inputStyle} />
</div>
<MSection label="Line" right="On hand / par" />
{rows.length === 0
? <MEmpty title="Nothing in stock yet" sub="This list is what has moved. Add a garment to the catalogue and scan some in, and it appears here." />
: rows.slice(0, 200).map((r) => (
<MRow key={r.key} mark={r.low ? "accent" : "ink"} attention={r.low}
title={r.name}
sub={[r.code || "No barcode bound", `par ${r.par}`, r.where].filter(Boolean).join(" · ")}
right={
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 21, fontVariantNumeric: "tabular-nums", color: r.low ? "var(--color-accent-700)" : INK }}>
{r.oh}<span style={{ color: "var(--color-neutral-700)", fontSize: 17 }}>/{r.par}</span>
</span>
} />
))}
<div style={{ padding: 16, display: "grid", gap: 12 }}>
<Link href="/m/reorder" style={link}><span style={{ flex: 1 }}>{low ? `Reorder ${low} line${low === 1 ? "" : "s"}` : "Reorder draft"}</span><IconRight /></Link>
<Link href="/m/variance" style={link}><span style={{ flex: 1 }}>Variance over time</span><IconRight /></Link>
<Link href="/m/label" style={link}><span style={{ flex: 1 }}>Reprint a label</span><IconRight /></Link>
{/* Stock only lists variants with history, so a garment added five minutes ago isnt here
yet. The catalogue is where it actually lives. */}
<Link href="/m/catalogue" style={link}><span style={{ flex: 1 }}>Catalogue</span><IconRight /></Link>
</div>
</MBody>
<MNav />
</>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
/* Variance over time the pattern, not the number. A line short at every count is a different
problem from one short once, so the chart is the point and the latest gap is the caption. */
import { useMemo } from "react";
import { useSnap } from "@/lib/client";
import { itemMap, monthLabel, variantName } from "@/lib/compute";
import { INK, MBody, MEmpty, MNav, MRule, MTop } from "@/components/m";
const MAX_BAR = 56;
export default function MVarianceOverTime() {
const { s } = useSnap();
const { rows, counts } = useMemo(() => {
const byId = itemMap(s);
// Oldest-first, shelf counts only, last six.
const takes = s.stocktakes.filter((t) => t.mode !== "preloved").slice(0, 6).reverse();
const seen: Record<string, { name: string; gaps: (number | null)[] }> = {};
takes.forEach((t, col) => {
for (const l of t.lines) {
const k = `${l.itemId}:${l.si}`;
const it = byId[l.itemId];
if (!it) continue;
(seen[k] ||= { name: variantName(it, it.sizes[l.si] ?? l.si), gaps: takes.map(() => null) });
seen[k].gaps[col] = l.counted - l.sys;
}
});
const out = Object.entries(seen).map(([k, v]) => {
const known = v.gaps.filter((g): g is number => g !== null);
const latest = [...v.gaps].reverse().find((g) => g !== null) ?? 0;
const shortEvery = known.length >= 2 && known.every((g) => g < 0);
const worsening = known.length >= 3 && known[known.length - 1] < known[0] && known[known.length - 1] < 0;
const verdict = known.every((g) => g === 0) ? "Steady"
: shortEvery ? `Short at every count since ${monthLabel(takes[v.gaps.findIndex((g) => g !== null)]?.date.slice(0, 7) || "", { month: "long" })}`
: worsening ? "Drifting short"
: latest === 0 ? "Back in line" : "Occasional gap";
const persistent = shortEvery || worsening;
return { key: k, name: v.name, gaps: v.gaps, latest, verdict, persistent };
});
// Worst pattern first: persistent problems, then biggest gap.
out.sort((a, b) => Number(b.persistent) - Number(a.persistent) || a.latest - b.latest || a.name.localeCompare(b.name));
return { rows: out, counts: takes };
}, [s]);
const peak = Math.max(1, ...rows.flatMap((r) => r.gaps.map((g) => Math.abs(g ?? 0))));
return (
<>
<MTop title="Variance" back right={`${counts.length} count${counts.length === 1 ? "" : "s"}`} />
<MRule />
<MBody>
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, letterSpacing: "-0.03em", lineHeight: 1.05 }}>What keeps going missing</h2>
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 8 }}>
{counts.length ? `Gap against expected at each count since ${monthLabel(counts[0].date.slice(0, 7), { month: "long" })}.` : "Nothing counted yet."}
</p>
</div>
{rows.length === 0 ? (
<MEmpty title="No counts to compare yet" sub="File two stocktakes and the pattern starts showing here." />
) : rows.slice(0, 40).map((r) => (
<div key={r.key} style={{ padding: "18px 16px 14px", background: r.persistent ? "#fff" : "var(--color-bg)", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 17, fontWeight: 700, letterSpacing: "-0.01em" }}>{r.name}</div>
<div style={{ fontSize: 13.5, color: "var(--color-neutral-600)", marginTop: 3 }}>{r.verdict}</div>
</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: r.latest === 0 ? 21 : 24, letterSpacing: "-0.02em", color: r.latest === 0 ? INK : "var(--color-accent-700)", fontVariantNumeric: "tabular-nums" }}>
{r.latest === 0 ? "Match" : r.latest > 0 ? `+${r.latest}` : `${-r.latest}`}
</div>
</div>
<div className="tcx-chart" style={{ marginTop: 18 }} role="img"
aria-label={`Gap at each count: ${r.gaps.map((g, i) => `${counts[i] ? monthLabel(counts[i].date.slice(0, 7), { month: "short" }) : ""} ${g === null ? "not counted" : g}`).join(", ")}`}>
{r.gaps.map((g, i) => {
const mag = Math.abs(g ?? 0);
const h = g === null ? 4 : Math.max(4, Math.round((mag / peak) * MAX_BAR));
const col = g === null ? "var(--color-neutral-300)" : mag === 0 ? "var(--color-divider)" : mag >= 3 ? "var(--color-accent)" : INK;
return <i key={i} style={{ height: h, background: col }} />;
})}
</div>
<div style={{ display: "flex", gap: 5, marginTop: 6 }}>
{counts.map((t, i) => (
<span key={i} style={{ flex: 1, textAlign: "center", fontSize: 10.5, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>
{monthLabel(t.date.slice(0, 7), { month: "short" })}
</span>
))}
</div>
</div>
))}
</MBody>
<MNav />
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import Analytics from "@/components/Analytics";
export const dynamic = "force-dynamic";
/* `title` has to be absolute here. As a plain string it went through the root layout's
"%s — ThreadCount" template and every screen of the phone app, sign-in included, was titled
"ThreadCount — ThreadCount" in the tab, in history and in a bookmark. The template is re-declared
for the screens below that name themselves, and the canonical points at the app rather than
inheriting the marketing homepage's. */
export const metadata: Metadata = {
title: { absolute: "ThreadCount — the linen room counter", template: "%s — ThreadCount" },
alternates: { canonical: "/m" },
robots: { index: false, follow: false },
};
/* The app is a fixed-height column: bars don't scroll, only the body does.
*
* No maximumScale. Pinning the zoom kept the layout tidy and took pinch-to-zoom away from everyone
* on every screen WCAG 1.4.4, and it matters more than tidiness on a ward phone held at arm's
* length in bad light. The bundled shell's own index.html had the same line removed. */
export const viewport = { width: "device-width", initialScale: 1, viewportFit: "cover" as const, themeColor: "#201e1d" };
/* Only the shell. Sign in and create account live under /m but must be reachable without a
session they are how you get one so the session check sits in (app) with everything else. */
export default function MobileLayout({ children }: { children: React.ReactNode }) {
return (
<div className="tcx-app" role="main">
{/* The shell itself is the main landmark. No skip link on the phone surfaces: navigation is
the bar at the bottom, after the content, so there is no repeated block in front to bypass.
role on the existing box rather than a <main> wrapper the shell is a fixed-height flex
column, and the display:contents that an extra element would need has a long history of
dropping the landmark out of the accessibility tree. */}
{children}
{/* Mounted on /m rather than inside (app): sign in and create account are app screens too,
and they are where the Android shell lands first. */}
<Analytics site="app" />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { Metadata } from "next";
/* Sign in is a client component and can't export metadata of its own, so it gets this. Without it
the page inherited the app shell's title and a browser tab, a history entry and a bookmark for
the sign-in screen all read as if they were the site's front page. */
export const metadata: Metadata = {
title: "Sign in",
alternates: { canonical: "/m/login" },
};
export default function MLoginLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+205
View File
@@ -0,0 +1,205 @@
"use client";
/* Sign in — onboarding screen 03. Public: this is the one /m route someone reaches without a
session, because it is how they get one. */
import Link from "next/link";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import Turnstile, { awaitTurnstile, resetTurnstile, turnstileOn } from "@/components/Turnstile";
import { track } from "@/lib/analytics";
import { MAuthError, MAuthFooter, MAuthHeader, MField, MShowHide, authInput, authLink } from "@/components/MAuth";
function LoginInner() {
const router = useRouter();
const sp = useSearchParams();
const [email, setEmail] = useState("");
const [pw, setPw] = useState("");
const [show, setShow] = useState(false);
const [cfToken, setCfToken] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [reveal, setReveal] = useState(false);
const [forgot, setForgot] = useState(false);
const [sentReset, setSentReset] = useState(false);
const [ticket, setTicket] = useState("");
const [code, setCode] = useState("");
const next = (() => {
const n = sp.get("next") || "";
return n.startsWith("/m") && !n.startsWith("//") ? n : "/m/signed-in";
})();
async function sendReset() {
setErr("");
if (!email.trim()) { setErr("Put your work email in the box above first."); return; }
setBusy(true);
const token = cfToken || (turnstileOn() ? await awaitTurnstile() : "");
await fetch("/api/auth/forgot", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ email, cfToken: token }),
}).catch(() => {});
setBusy(false);
setCfToken(""); resetTurnstile();
// Shown whatever the server said: the answer must not reveal whether the address has an account.
setSentReset(true);
}
async function submitCode() {
setErr("");
if (!code.trim()) { setErr("Enter the six-digit code from your authenticator app."); return; }
setBusy(true);
const r = await fetch("/api/auth/2fa", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ ticket, code }),
}).catch(() => null);
const j = r ? await r.json().catch(() => ({})) : {};
setBusy(false);
// A dropped connection is not a wrong code, and it used to leave the button spinning for ever
// with nothing said. Nothing is signed in either way, so the advice is simply to try again.
if (!r) {
track("signin_failed", { reason: "network" });
setErr("Couldnt reach the server. Check the connection and try again.");
return;
}
if (!r.ok) {
track("signin_failed", { reason: "second_factor" });
setErr(j.error || "That code isnt right.");
if (r.status === 400) setTicket(""); // the ticket expired — start again
return;
}
track("signin");
window.location.replace(next);
}
async function submit() {
if (!email.trim() || !pw) { setErr("Enter your email and password."); return; }
setBusy(true);
// The widget draws nothing in quiet mode, so nobody can see that it hasn't finished. Wait for
// the token rather than posting an empty one and blaming the person for it.
const token = cfToken || (turnstileOn() ? await awaitTurnstile() : "");
if (turnstileOn() && !token) {
// Eight seconds and no token. Either the check needs an interaction we've asked Turnstile
// not to draw, or it couldn't reach Cloudflare at all. Show the widget rather than send an
// empty token and let the server answer with a check the person was never shown.
setBusy(false);
setReveal(true);
// How often the invisible check has to show itself. If this climbs, the quiet widget is
// costing people sign-ins and should come back out.
track("security_check_shown", { screen: "signin" });
setErr("Finish the security check below, then try again.");
return;
}
const r = await fetch("/api/auth/login", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password: pw, cfToken: token }),
}).catch(() => null);
const j = r ? await r.json().catch(() => ({})) : {};
setBusy(false);
/* The request never arrived. Ward wifi drops, and without this the promise rejected, `busy`
never cleared and the sign-in button sat disabled on its spinner until the app was killed
which reads as ThreadCount refusing to let you in. Nothing was signed in, so the token is
spent and the widget reset like any other failed attempt. */
if (!r) {
track("signin_failed", { reason: "network" });
setErr("Couldnt reach the server. Check the connection and try again.");
setCfToken(""); resetTurnstile();
return;
}
if (!r.ok) {
// Whether it was the password or the security check — no email, no message.
track("signin_failed", { reason: r.status === 429 ? "throttled" : r.status === 400 ? "security_check" : "credentials" });
setErr(j.error || "That email and password dont match.");
setCfToken(""); resetTurnstile();
return;
}
// The password was right but the account has a second factor; nothing is signed in yet.
if (j.need2fa) { setTicket(j.ticket); track("signin_2fa_required"); return; }
track("signin");
// A full navigation, not a router push: the session cookie has just changed and every /m page
// is server-rendered from it. replace(), not assign(), so the hardware back button doesn't
// land a signed-in person back on the sign-in screen.
window.location.replace(next);
}
return (
<>
<MAuthHeader kicker="Linen room access" title="Sign in" />
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
<div style={{ flex: 1, overflowY: "auto", padding: "26px 24px 30px", display: "grid", gap: 26, alignContent: "start" }}>
<p style={{ fontSize: 14, lineHeight: 1.5, color: "var(--color-neutral-800)", maxWidth: 290 }}>
{ticket ? "Your password was right. Now the code from your authenticator app." : "Use the account your linen services manager set up."}
</p>
<MAuthError msg={err} />
{ticket ? (
<>
<MField n="01" label="Six-digit code">
{(c) => (
<input {...c} style={authInput} inputMode="numeric" autoComplete="one-time-code" autoFocus
autoCapitalize="none" autoCorrect="off" spellCheck={false} enterKeyHint="go"
placeholder="000000" value={code}
onChange={(e) => { setCode(e.target.value); setErr(""); }}
onKeyDown={(e) => { if (e.key === "Enter") submitCode(); }} />
)}
</MField>
<p style={{ fontSize: 12.5, lineHeight: 1.6, color: "var(--color-neutral-700)" }}>
Lost your phone? A recovery code works here instead.
</p>
</>
) : (
<>
<MField n="01" label="Work email">
{(c) => (
<input {...c} style={authInput} type="email" inputMode="email" autoCapitalize="none" autoCorrect="off"
spellCheck={false} autoComplete="email" enterKeyHint="next" placeholder="you@yourfacility.org"
value={email} onChange={(e) => { setEmail(e.target.value); setErr(""); }} />
)}
</MField>
<MField n="02" label="Password" right={<MShowHide on={show} onToggle={() => setShow(!show)} />}>
{(c) => (
<input {...c} style={authInput} type={show ? "text" : "password"} autoComplete="current-password"
enterKeyHint="go" value={pw}
onChange={(e) => { setPw(e.target.value); setErr(""); }}
onKeyDown={(e) => { if (e.key === "Enter") submit(); }} />
)}
</MField>
</>
)}
{/* This used to explain that there was no reset and to go and find an admin which was a
dead end for the admin themselves, and deleting the last admin deletes the facility.
Hidden during the code step, where it would be answering a question nobody asked. */}
{ticket ? null : sentReset ? (
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--color-neutral-800)", background: "#fff", borderLeft: "6px solid var(--color-accent)", padding: 14 }}>
If that address has an account, a reset link is on its way. It works once and expires in
an hour.
</p>
) : forgot ? (
<div style={{ background: "#fff", borderLeft: "6px solid var(--color-text)", padding: 14 }}>
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--color-neutral-800)", margin: 0 }}>
Put your work email in the box above and we&rsquo;ll send a link to set a new password.
</p>
<button onClick={sendReset} disabled={busy}
style={{ marginTop: 12, background: "none", border: 0, padding: 0, fontSize: 13.5, fontWeight: 800, color: "var(--color-accent-700)", cursor: busy ? "wait" : "pointer" }}>
{busy ? "Sending…" : "Send me a reset link"}
</button>
</div>
) : (
<button onClick={() => setForgot(true)}
style={{ justifySelf: "start", background: "none", border: 0, padding: 0, fontSize: 13, fontWeight: 600, color: "var(--color-accent-700)", cursor: "pointer" }}>
Forgot password
</button>
)}
{turnstileOn() && <Turnstile onToken={setCfToken} action="login" quiet={!reveal} />}
</div>
<MAuthFooter
secondary={ticket
? <button onClick={() => { setTicket(""); setCode(""); setErr(""); }} style={{ background: "none", border: 0, padding: 0, font: "inherit", fontSize: 13, fontWeight: 700, color: "var(--color-accent-700)", cursor: "pointer" }}>Start again</button>
: <>No account yet? <Link href="/m/signup" style={authLink}>Sign up</Link></>}
label={ticket ? "Verify" : "Sign in"} onSubmit={ticket ? submitCode : submit} busy={busy} />
</>
);
}
export default function MLogin() {
return <Suspense fallback={null}><LoginInner /></Suspense>;
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
/* Same reason as the sign-in screen next door: a client page can't name itself, and "create an
account" is not the site's front page. */
export const metadata: Metadata = {
title: "Create an account",
alternates: { canonical: "/m/signup" },
};
export default function MSignupLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+11
View File
@@ -0,0 +1,11 @@
import { switches } from "@/lib/switches";
import MSignup from "@/components/MSignup";
/* Create account onboarding screen 04. The screen itself is components/MSignup.tsx; this wrapper
exists to tell it whether plans are live, which only the server knows. */
export const dynamic = "force-dynamic";
export default async function MSignupPage() {
const sw = await switches();
return <MSignup plansLive={sw.plansLive} />;
}
+11
View File
@@ -0,0 +1,11 @@
import { redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import AccountScreen from "@/components/screens/Account";
export const dynamic = "force-dynamic";
export default async function MyAccount() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
return <AccountScreen email={sess.email} />;
}
+17
View File
@@ -0,0 +1,17 @@
import { notFound, redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { reviewData } from "@/lib/managerdata";
import ReviewScreen from "@/components/screens/Review";
export const dynamic = "force-dynamic";
export default async function MyReview({ params }: { params: Promise<{ id: string }> }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { id } = await params;
// reviewData starts from `managerId: sess.staffId`, so a request addressed to another manager
// is not filtered out afterwards — it is never selected.
const data = await reviewData(sess, id);
if (!data) notFound();
return <ReviewScreen data={data} />;
}
+43
View File
@@ -0,0 +1,43 @@
import { notFound, redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { currentStaff } from "@/lib/staffsession";
import { approvalQueue } from "@/lib/managerdata";
import ApprovalsScreen from "@/components/screens/Approvals";
export const dynamic = "force-dynamic";
export default async function MyApprovals() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
// Whoever a request names decides it, team or no team. A request reaches somebody who manages
// nobody more than one way: the linen room re-addresses one that arrived without an approver
// (request.reassign only checks the person is on the register, not that anybody reports to
// them), or a manager's last report is moved to somebody else while their request is still
// awaiting. This is the same query Home counts for its "waiting on you" banner, and the banner
// is the only door — the staff nav has no approvals tab — so turning these people away left a
// colleague blocked behind a 404 nobody could clear.
const rows = await approvalQueue(sess);
if (!rows.length) {
// Nothing waiting and nobody reporting to them gets nothing, not an empty queue: an empty
// approvals screen implies they might one day have a team, which is a question for the linen
// room and not something this app should imply an answer to.
const reports = await prisma.staff.count({ where: { managerId: sess.staffId } });
if (!reports) notFound();
}
// Which of the waiting requests are for the manager themselves. Some of these now are — the
// rule against approving your own uniform has been relaxed for the case the owner named — and
// the screen sets those apart so nobody approves their own by accident and works out later that
// they did. Whether a self-approval is allowed at all is the server's call and is not re-tested
// here; this only asks the database which of the rows it already let through are the reader's
// own, by the request's subject, which is the same fact the record is written from. The rows are
// scoped to this manager and to `awaiting` already, so the lookup is over a handful of ids.
const ownIds = rows.length
? (
await prisma.request.findMany({
where: { id: { in: rows.map((r) => r.id) }, subjectId: sess.staffId },
select: { id: true },
})
).map((r) => r.id)
: [];
return <ApprovalsScreen rows={rows} ownIds={ownIds} />;
}
+13
View File
@@ -0,0 +1,13 @@
import { redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { catalogueData, damageData } from "@/lib/staffdata";
import DamageScreen from "@/components/screens/Damage";
export const dynamic = "force-dynamic";
export default async function MyDamage() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const [{ holdings }, { managerName }] = await Promise.all([damageData(sess), catalogueData(sess)]);
return <DamageScreen holdings={holdings} managerName={managerName} />;
}
+12
View File
@@ -0,0 +1,12 @@
import { redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { kitData } from "@/lib/staffdata";
import KitScreen from "@/components/screens/Kit";
export const dynamic = "force-dynamic";
export default async function MyKit() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
return <KitScreen data={await kitData(sess)} />;
}
+16
View File
@@ -0,0 +1,16 @@
import { notFound, redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { kitCheckData } from "@/lib/cycledata";
import KitCheckScreen from "@/components/screens/KitCheck";
export const dynamic = "force-dynamic";
export default async function MyKitCheck() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const data = await kitCheckData(sess);
// Between rounds there is no screen. A kit check that was always reachable would be answered at
// random times and the cycle's numbers would mean nothing.
if (!data) notFound();
return <KitCheckScreen dueBy={data.dueBy} lastConfirmed={data.lastConfirmed} rows={data.rows} />;
}
+42
View File
@@ -0,0 +1,42 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { currentStaff } from "@/lib/staffsession";
import { StaffProvider, type StaffMe } from "@/lib/staffclient";
export const dynamic = "force-dynamic";
/* Everything that needs a signed-in staff member.
*
* The role flags are resolved here, once, from the database rather than trusted from the client:
* "am I a manager" is the answer to "does anybody name me as theirs", and a screen that asked the
* browser that question would be asking the wrong party.
*/
export default async function StaffAppLayout({ children }: { children: React.ReactNode }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const [staff, reports] = await Promise.all([
prisma.staff.findUniqueOrThrow({
where: { id: sess.staffId },
select: { first: true, last: true, num: true, dept: true, wardDesk: true, managerId: true, facility: { select: { name: true, timezone: true } } },
}),
prisma.staff.count({ where: { managerId: sess.staffId, inactive: false } }),
]);
const me: StaffMe = {
staffId: sess.staffId,
name: `${staff.first} ${staff.last}`.trim(),
first: staff.first,
num: staff.num,
ward: staff.dept,
facility: staff.facility.name,
// Resolved here for the same reason the role flags are: it is the facility's answer, not the
// phone's, and a device set to the wrong zone must not change what a ward round is told.
tz: staff.facility.timezone,
isManager: reports > 0,
wardDesk: staff.wardDesk,
hasManager: !!staff.managerId,
};
return <StaffProvider me={me}>{children}</StaffProvider>;
}
+55
View File
@@ -0,0 +1,55 @@
/* What a tap looks like before the server answers.
*
* Every screen under /my is `force-dynamic` and rendered from its own database query, and App
* Router keeps the previous screen fully painted until that query comes back. On ward wifi that is
* two or three seconds in which nothing at all acknowledges the tap so people tap again, and the
* app reads as frozen. This is the route-level fallback the framework wants for exactly that: it
* replaces the body the moment a navigation starts, keeping the app's own chrome so the change
* reads as "loading" rather than "gone".
*
* Deliberately not the tab bar: the nav belongs to the four screens that draw it, and painting one
* here would make it flash into existence on the way to a detail screen that has none. The top bar
* has no title for the same reason this fallback covers every route in the group, and inventing a
* title would mean printing the wrong one somewhere.
*/
const INK = "#201e1d";
const GROUND = "#f3f2f2";
/** A grey block standing in for a line of text. Sized in the same 2px system as everything else. */
function Bar({ w, h = 16 }: { w: string; h?: number }) {
return <div style={{ width: w, height: h, background: "var(--color-neutral-200)" }} />;
}
export default function StaffLoading() {
return (
<>
<header className="tcx-topbar" style={{
height: 56, flex: "0 0 56px", background: INK, color: GROUND, display: "flex", alignItems: "center",
paddingLeft: 16, paddingRight: 16,
backgroundImage: "linear-gradient(to bottom, rgba(243,242,242,0.16) 0 1px, transparent 1px)",
backgroundPosition: "0 env(safe-area-inset-top, 0px)", backgroundRepeat: "no-repeat", backgroundSize: "100% 1px",
}}>
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.06em", textTransform: "uppercase" }}>
One moment
</span>
</header>
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
<div style={{ flex: 1, overflow: "hidden", background: GROUND }} aria-busy="true">
{/* Announced once, quietly. The blocks below are decoration and say nothing. */}
<div role="status" style={{ padding: "20px 16px 0", fontSize: 13, color: "var(--color-neutral-600)" }}>Loading</div>
<div style={{ padding: "16px 16px 0", display: "grid", gap: 10 }} aria-hidden="true">
<Bar w="60%" h={22} />
<Bar w="40%" />
</div>
<div style={{ marginTop: 24, display: "grid", gap: 2 }} aria-hidden="true">
{[0, 1, 2, 3].map((i) => (
<div key={i} style={{ background: "#fff", padding: "18px 16px", display: "grid", gap: 8 }}>
<Bar w="55%" h={18} />
<Bar w="35%" h={12} />
</div>
))}
</div>
</div>
</>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { MBar, MBody, MEmpty, MRule, MTop } from "@/components/m";
/* What a staff screen shows when there is nothing behind it.
*
* Nine routes under /my call notFound(): a kit check between rounds, the waitlist with nothing
* offered, the ward and desk screens for somebody without that role, an order that isn't theirs.
* Without this file every one of them rendered the WEBSITE's 404 marketing nav, "Open the demo",
* a footer inside the app, over the top of the tab bar, with the hardware back button as the
* only way home. Seen on a Pixel 8 Pro on 2026-09-12 by opening Kit check with no check open.
*
* Next renders the nearest not-found.tsx, so this one stays inside the signed-in layout: same
* chrome, same provider, and a bar that goes home.
*/
export default function StaffNotFound() {
return (
<>
<MTop title="Nothing here" back />
<MRule />
<MBody>
<MEmpty
title="Nothing to show right now"
sub="Theres no screen behind that link at the moment — a kit check that isnt open, a list with nothing on it, or something that isnt yours to see. Nothing on your record has changed."
/>
</MBody>
<MBar label="Back to home" href="/my" />
</>
);
}
@@ -0,0 +1,15 @@
import { notFound, redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { requestData } from "@/lib/staffdata";
import ThreadScreen from "@/components/screens/Thread";
export const dynamic = "force-dynamic";
export default async function MyOrderThread({ params }: { params: Promise<{ id: string }> }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { id } = await params;
const data = await requestData(sess, id);
if (!data) notFound();
return <ThreadScreen data={data} />;
}
+17
View File
@@ -0,0 +1,17 @@
import { notFound, redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { requestData } from "@/lib/staffdata";
import OrderScreen from "@/components/screens/Order";
export const dynamic = "force-dynamic";
export default async function MyOrder({ params }: { params: Promise<{ id: string }> }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { id } = await params;
const data = await requestData(sess, id);
// A request that isn't theirs, their team's, or one they raised is a 404 rather than a 403 —
// "you may not see this" confirms it exists.
if (!data) notFound();
return <OrderScreen data={data} />;
}
+24
View File
@@ -0,0 +1,24 @@
import { redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { ordersData } from "@/lib/staffdata";
import OrdersScreen from "@/components/screens/Orders";
export const dynamic = "force-dynamic";
export default async function MyOrders({ searchParams }: { searchParams: Promise<{ tab?: string }> }) {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const { tab } = await searchParams;
const { open, done, raised } = await ordersData(sess);
// Landing from the Messages tab with nothing open should still show the Open tab and its empty
// state, rather than a list of finished orders nobody asked for. `raised` is what this person
// typed in for somebody else — the desk's whole day, and now a manager's too.
return (
<OrdersScreen
open={open}
done={done}
raised={raised}
initialTab={tab === "done" ? "done" : tab === "raised" ? "raised" : "open"}
/>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { currentStaff } from "@/lib/staffsession";
import { homeData } from "@/lib/staffdata";
import { fmtDate } from "@/lib/compute";
import { ROUTED_TO_ROUND, dueOnWard } from "@/lib/staffreq";
import HomeScreen from "@/components/screens/Home";
export const dynamic = "force-dynamic";
export default async function MyHome() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const data = await homeData(sess);
// A manager's queue and a clerk's trolley are counts here, not lists: Home answers "is anything
// waiting for me?" and then gets out of the way. Both are skipped entirely for the people the
// flags don't apply to, which is nearly everyone.
const [approvals, reports, roundBags, cycle] = await Promise.all([
prisma.request.count({ where: { managerId: sess.staffId, status: "awaiting" } }),
/* Whether anybody reports to them, which is the same question /my/raise answers with a 404.
* Not the same as having approvals waiting: a manager whose team has asked for nothing this
* month still needs the door, and this is the only way in to it. */
prisma.staff.count({ where: { managerId: sess.staffId, inactive: false } }),
data.wardDesk && data.ward
// The ward the trolley left the bag on, off the timeline — the same fence /my/round and
// round.sign use (see roundWard() in lib/staffreq.ts). Counting on the wearer's current ward
// made this badge disagree with the screen it opens the moment anybody transferred wards
// mid-round: a bag counted here and missing from the round, or the other way about.
? prisma.request.count({
where: {
facilityId: sess.facilityId, status: "round",
events: { some: { label: ROUTED_TO_ROUND, meta: dueOnWard(data.ward) } },
},
})
: Promise.resolve(0),
prisma.kitCheck.findFirst({
where: { facilityId: sess.facilityId, closedAt: null },
orderBy: { openedAt: "desc" },
select: { id: true, dueBy: true },
}),
]);
// Only prompt for a cycle they still owe answers to — someone who finished last week should not
// be nagged for the rest of the month.
let kitCheckDue: string | null = null;
if (cycle) {
const [held, answered] = await Promise.all([
// handedIn as well as returnedDate: a garment handed back at the counter never gets marked
// returned, so counting on returnedDate alone nags somebody for a kit check about uniform
// they gave back months ago. Every equivalent query in lib/ reads both.
prisma.issue.count({ where: { staffId: sess.staffId, returnedDate: null, handedIn: null } }),
prisma.kitCheckAnswer.count({ where: { kitCheckId: cycle.id, staffId: sess.staffId } }),
]);
if (held > 0 && answered === 0) kitCheckDue = fmtDate(cycle.dueBy);
}
return (
<HomeScreen
data={data}
approvals={approvals}
roundBags={roundBags}
kitCheckDue={kitCheckDue}
canRaiseForTeam={reports > 0}
/>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { notFound, redirect } from "next/navigation";
import { currentStaff } from "@/lib/staffsession";
import { deskCatalogue, teamPeople } from "@/lib/deskdata";
import { ordersData } from "@/lib/staffdata";
import { REQUEST_MAX_LINES, REQUEST_MAX_QTY } from "@/lib/ops";
import DeskScreen from "@/components/screens/Desk";
export const dynamic = "force-dynamic";
/* A manager raising for one of their own reports the only way one person types a request in
* somebody else's name in this app. A ward clerk on the desk used to have a screen of its own for
* anyone on their ward; that is gone, and the person who would have asked the clerk asks the
* manager who approves it anyway.
*
* The scope here is the same relationship the server enforces on `request.create`: the people who
* name this person as their manager. Nothing on this page decides who may be raised for it asks
* teamPeople() for exactly the set the op would accept, and a request for anybody else is refused
* there.
*/
export default async function MyRaise() {
const sess = await currentStaff();
if (!sess) redirect("/my/signin");
const [people, items, orders] = await Promise.all([
teamPeople(sess),
deskCatalogue(sess),
ordersData(sess),
]);
// Nobody reporting to them means no screen, the same way the approvals queue works: an empty one
// implies they might one day have a team, which is a question for the linen room.
if (!people.length) notFound();
return (
<DeskScreen
// Every one of them names this manager, which is exactly why the request cannot stay with
// them: the screen says whose name is on it, and the server sends it up a level.
people={people}
items={items}
raised={orders.raised.open}
maxLines={REQUEST_MAX_LINES}
maxQty={REQUEST_MAX_QTY}
/>
);
}

Some files were not shown because too many files have changed in this diff Show More