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 38e16eb on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-16 07:57:54 +10:00
commit 0910bc32c1
457 changed files with 55952 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 38e16eb
+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 });
}
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentStaff } from "@/lib/staffsession";
import { parseDataUrl, readPhoto } from "@/lib/photostore";
export const dynamic = "force-dynamic";
/* The signature on one of a staff member's own signed slips, for the staff app.
*
* Only the caller's own slip, and only one the counter sent to their app (toStaff). Any other id is
* simply not found, whoever's it is. Served with the same headers as /api/photo/[id]. */
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const sess = await currentStaff();
if (!sess) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const { id } = await ctx.params;
const slip = await prisma.slip.findFirst({
where: { id: String(id || "").slice(0, 40), staffId: sess.staffId, facilityId: sess.facilityId, toStaff: true },
select: { sigId: true, facilityId: true },
});
if (!slip?.sigId) return NextResponse.json({ error: "Not found" }, { status: 404 });
const ph = await prisma.photo.findFirst({
where: { id: slip.sigId, facilityId: slip.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",
},
});
}
+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>
);
}
+1433
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>
);
}
+466
View File
@@ -0,0 +1,466 @@
"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.
*
* `?bind=<code>` arrives from a scan that found nothing: each size offers to take that code. */
import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { bcBound, formatInZone, key as vkey, label, money, onhand, reorderAt, splitKey, type CostRec, type Item } from "@/lib/compute";
import { printItemLabels } from "@/lib/nativeprint";
import MScan from "@/components/MScan";
import {
ACCENT, GROUND, INK, ON_DARK, MBar, MBody, MButton, MEmpty, MError, MField, MONO, MPill, MRule, MSection, MTop, MTopCount, inputStyle, useToast,
} 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. */
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: 13, fontWeight: 700, color: "var(--color-neutral-700)",
textAlign: "left", cursor: "pointer",
};
/* A freshly minted number exists in ThreadCount at once, but nothing is on the garment until it is
printed, so the confirmation carries the print with it. */
function MMade({ made, labels, onPrint }: { made: { size: string; code: string }[]; labels: number; onPrint: () => void }) {
if (!made.length) return null;
return (
<div style={{ margin: "10px 0 0", padding: 16, background: INK, color: GROUND, fontSize: 14 }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 16 }}>
{made.length === 1 ? `Size ${made[0].size} has a barcode` : `${made.length} sizes have a barcode`}
</div>
<div style={{ marginTop: 8, color: ON_DARK, fontSize: 13, fontFamily: MONO }}>
{made.map((m) => <div key={m.code}>{m.size} · {m.code}</div>)}
</div>
{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} label{labels === 1 ? "" : "s"}
</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 sp = useSearchParams();
const toast = useToast();
const bindRaw = (sp.get("bind") || "").trim().slice(0, 80);
const bindCode = isAdmin ? bindRaw : "";
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. -1 is the whole garment. */
const [genFor, setGenFor] = useState<number | null>(null);
const [made, setMade] = useState<{ si: number; size: string; code: string }[]>([]);
// Binding is admin-only; an Issuer who arrives with a code goes back to the catalogue.
useEffect(() => { if (bindRaw && !isAdmin) router.replace("/m/catalogue"); }, [bindRaw, isAdmin, router]);
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 pad><MEmpty title="No such garment" sub="It isnt in the catalogue any more." /></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 many labels a print run would produce —
// one per garment on the shelf in a size that carries a code.
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, and saving a stale form would put an
* old price back in someone else's name. */
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: when the code already sits on another
* garment, offer the move rather than printing the message and stopping there. A generated
* 93XXXXXXX code is refused with or without force and is shown as it came. */
async function bind(si: number, raw: string): Promise<boolean> {
const code = raw.trim();
if (!code) return false;
setErr(""); setMade([]);
const r = await mutate("barcode.bind", { code, itemId: it!.id, si });
if (r.ok) { setTypeFor(null); setTyped(""); return true; }
const at = boundElsewhere(code);
if (!at && !/is already on/.test(r.error)) { setErr(r.error); return false; }
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 false; }
const moved = await mutate("barcode.bind", { code, itemId: it!.id, si, force: true });
if (!moved.ok) { setErr(moved.error); return false; }
setTypeFor(null); setTyped("");
return true;
}
async function bindScanned(si: number) {
if (!(await bind(si, bindCode))) return;
toast(`${bindCode} bound to size ${it!.sizes[si]}`);
router.replace(`/m/catalogue/${encodeURIComponent(it!.id)}`);
}
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; removing shifts later sizes down a place, so anything
* held open against a position lets 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([]);
}
/* Our own barcode for stock that arrived without one. The server fills only the gaps and refuses
* when there is nothing to do, so the offer is made and whatever comes back is shown. */
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);
}
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. Android
printing in the app; a printable page in a browser. */
async function printLabels() {
const r = await printItemLabels({ itemId: it!.id });
if (!r.ok) setErr(r.error);
}
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");
}
const bigBtn = (tone: "line" | "accent" | "ink"): React.CSSProperties => ({
width: "100%", minHeight: 52, border: "2px solid " + (tone === "accent" ? ACCENT : INK),
background: tone === "accent" ? ACCENT : tone === "ink" ? INK : "transparent", color: tone === "line" ? INK : tone === "ink" ? GROUND : "#fff",
font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer",
});
return (
<>
<MTop title={it.archived ? "Archived" : "Garment"} right={<MTopCount>{it.sizes.length} size{it.sizes.length === 1 ? "" : "s"}</MTopCount>} back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
{(it.archived || bindCode) && (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 10 }}>
{it.archived && <MPill tone="accent">Archived</MPill>}
{bindCode && <MPill tone="accent" mono>Binding {bindCode}</MPill>}
</div>
)}
{/* ---- the description ---- */}
{!editing ? (
<>
<div style={{ paddingBottom: 14, borderBottom: "1px solid var(--color-divider)" }}>
<h2 style={{ fontWeight: 900, fontSize: 24, lineHeight: 1.1, margin: 0 }}>{name}</h2>
<div style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 6 }}>
{[it.type, it.group === "All" ? "Anyone" : it.group, it.supplier, it.sku].filter(Boolean).join(" · ") || "No details yet"}
</div>
<div style={{ fontSize: 14, fontFamily: MONO, marginTop: 4 }}>{it.cost ? `${money(it.cost)} each` : "No unit cost"}</div>
{it.notes && <div style={{ fontSize: 13, color: "var(--color-neutral-800)", marginTop: 8 }}>{it.notes}</div>}
</div>
{!readOnly && !bindCode && (
<div style={{ padding: "14px 0", borderBottom: "2px solid " + INK }}>
<button onClick={startEdit} style={bigBtn("line")}>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: "14px 0", display: "grid", gap: 10, borderBottom: "2px solid " + INK }}>
<button onClick={saveDetails} disabled={busy} style={bigBtn("accent")}>{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: 14, 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 fallback is printed on no garment.
const code = bcBound(s, it, si);
return (
<div key={si} style={{ padding: "14px 0", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ fontFamily: MONO, fontWeight: 600, fontSize: 18, 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, fontFamily: MONO }}>{oh}</b> on hand
</div>
<div style={{ fontSize: 12, fontFamily: MONO, 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 aria-label={`Par for ${sz}: ${par}`} style={{ minWidth: 44, height: 44, background: INK, color: GROUND, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: MONO, 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 && bindCode && code !== bindCode && (
<MButton small tone="ink" label={`Bind to size ${sz}`} disabled={busy} onClick={() => bindScanned(si)} />
)}
{!readOnly && !bindCode && (
<div style={{ marginTop: 10 }}>
{typeFor === si ? (
<div style={{ display: "grid", gap: 8 }}>
{/* A numeric keypad hint only: whatever arrives is taken as typed. */}
<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>
{/* Offered only where nothing is bound: a supplier's printed code always 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} labels={labels} onPrint={printLabels} />}
</div>
)}
</div>
);
})}
{!readOnly && !bindCode && (
<div style={{ padding: "14px 0", borderBottom: "2px solid " + INK, display: "flex", gap: 10 }}>
<input value={newSize} onChange={(e) => setNewSize(e.target.value)} placeholder="Add a size" aria-label="New 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 && !bindCode && (
<>
<MSection label="Barcodes" right={unlabelled ? `${unlabelled} without` : "All labelled"} />
{made.length > 1 && <MMade made={made} labels={labels} onPrint={printLabels} />}
<div style={{ padding: "14px 0", display: "grid", gap: 10, borderBottom: "2px solid " + INK }}>
<button onClick={generateAll} disabled={busy} style={{ ...bigBtn("line"), opacity: busy ? 0.5 : 1 }}>
{genFor === -1 ? "Generating…" : "Generate for every unlabelled size"}
</button>
<button onClick={printLabels} disabled={labels === 0}
style={{ ...bigBtn("accent"), cursor: labels === 0 ? "not-allowed" : "pointer", opacity: labels === 0 ? 0.4 : 1 }}>
{labels ? `Print ${labels} label${labels === 1 ? "" : "s"}` : "Nothing to print"}
</button>
</div>
</>
)}
{!readOnly && !bindCode && (
<div style={{ padding: "14px 0" }}>
<button onClick={archive} style={{ ...quietAction, color: it.archived ? "var(--color-accent-700)" : "var(--color-neutral-700)" }}>
{it.archived ? "Put this garment back in the catalogue" : "Archive this garment"}
</button>
</div>
)}
{/* What we used to pay: a price rise would otherwise erase the old figure. */}
{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 0", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ fontFamily: MONO, fontWeight: 600, fontSize: 15, minWidth: 78 }}>{money(c.cost)}</div>
<div style={{ flex: 1, minWidth: 0, fontSize: 13, color: "var(--color-neutral-700)" }}>
{c.previous === null ? "Opening price" : `${c.previous > c.cost ? "Down" : "Up"} from ${money(c.previous)}`}
{" · "}
{/* The facility's zone, not the device's, so server and browser render the same day. */}
{formatInZone(c.at, s.tz)}
</div>
<div style={{ fontSize: 12, color: "var(--color-neutral-600)", whiteSpace: "nowrap" }}>{c.byName}</div>
</div>
))}
</>
)}
</MBody>
{!readOnly && !it.archived && !bindCode && <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); }}
/>
)}
</>
);
}
+136
View File
@@ -0,0 +1,136 @@
"use client";
/* Create a garment from the counter.
*
* 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. Sizes are entered as a
* run rather than one at a time, because that is how they arrive. */
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, MEmpty, MError, MField, 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; groups already in use on the register or
* the catalogue are folded in so none that predates the configured list vanishes. */
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 pad><MEmpty title="Admins only" sub="An admin adds garments to the catalogue." /></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 />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
<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 role="group" aria-label="Common size runs" style={{ paddingBottom: 12, display: "flex", flexWrap: "wrap", gap: 8 }}>
{COMMON_RUNS.map(([run, pretty]) => (
<button key={run} type="button" aria-pressed={sizeText === run} onClick={() => { setSizeText(run); setErr(""); }}
style={{ minHeight: 44, border: "2px solid " + INK, background: sizeText === run ? INK : "transparent", color: sizeText === run ? "var(--color-bg)" : INK, padding: "0 12px", fontFamily: "inherit", fontSize: 13, fontWeight: 700, cursor: "pointer" }}>
{pretty}
</button>
))}
</div>
{sizes.length > 0 && (
<div style={{ paddingBottom: 12, 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>
</MBody>
<MBar label={busy ? "Saving…" : "Create garment"} onClick={save} disabled={busy} />
</>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
/* The whole catalogue on the phone, not just what's on the shelf: Stock lists only sizes with
* history, so a garment created five minutes ago lives here. `?bind=<code>` arrives from a scan that
* found nothing; picking a garment carries the code to its product card (admins only). */
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { label, money, type Item } from "@/lib/compute";
import { MBody, MButton, MEmpty, MPill, MRow, MRule, MSearch, MSection, MTop, MTopCount } from "@/components/m";
const PAGE = 300;
export default function MCatalogue() {
const { s, isAdmin } = useSnap();
const { byId } = useDerived();
const router = useRouter();
const sp = useSearchParams();
const bindRaw = (sp.get("bind") || "").trim().slice(0, 80);
const bind = isAdmin ? bindRaw : "";
const [q, setQ] = useState("");
const [showArchived, setShowArchived] = useState(false);
const [shown, setShown] = useState(PAGE);
// Binding is an admin job; an Issuer arriving with a code just gets the catalogue.
useEffect(() => { if (bindRaw && !isAdmin) router.replace("/m/catalogue"); }, [bindRaw, isAdmin, router]);
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", i.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 cardHref = (id: string) => `/m/catalogue/${encodeURIComponent(id)}${bind ? `?bind=${encodeURIComponent(bind)}` : ""}`;
return (
<>
<MTop title="Catalogue" back right={<MTopCount>{rows.length}</MTopCount>} />
<MRule />
<MBody pad>
<MSearch value={q} onChange={(v) => { setQ(v); setShown(PAGE); }} placeholder="Garment, code, supplier" label="Filter the catalogue" />
{bind && <div style={{ marginTop: 10 }}><MPill tone="accent" mono>Binding {bind}</MPill></div>}
{isAdmin && !bind && <MButton icon="plus" label="New garment" href="/m/catalogue/new" />}
<MSection label={showArchived ? "Archived" : "Garments"} right={rows.length} />
{rows.length === 0
? <MEmpty title={q ? "Nothing matches" : showArchived ? "Nothing archived" : "No garments yet"}
sub={q ? "Try a shorter search." : undefined} />
: rows.slice(0, shown).map((i) => (
<MRow key={i.id} href={isAdmin ? cardHref(i.id) : undefined} chev={isAdmin}
mark={i.archived ? "mute" : "ink"} title={i.name} sub={i.sub}
right={i.cost ? money(i.cost) : undefined} />
))}
{rows.length > shown && (
<>
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 10 }}>Showing {shown} of {rows.length}</div>
<MButton small label="Show more" onClick={() => setShown((n) => n + PAGE)} />
</>
)}
{archivedCount > 0 && (
<MButton small label={showArchived ? "Current catalogue" : `Show ${archivedCount} archived`}
onClick={() => { setShowArchived(!showArchived); setShown(PAGE); }} />
)}
</MBody>
</>
);
}
+210
View File
@@ -0,0 +1,210 @@
"use client";
/* Counting: scan a garment and its own line goes up by one and becomes the line being counted.
Expected figures stay on screen (a sighted count, as the phone has always been). The tally lives
in localStorage (lib/opencount.ts), so backgrounding the app mid-shelf loses nothing.
Hands-free keeps the camera reading continuously. Inside the Android shell MLKit runs with its
preview behind this opaque screen, so the panel and the list stay in view and each garment is a
beep, a buzz and the figures moving (handsfree.png). In a browser the camera needs a visible,
playing <video>, so hands-free opens the live camera overlay with the same panel and switch. */
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSnap } from "@/lib/client";
import { UNPLACED } from "@/lib/compute";
import MScan from "@/components/MScan";
import { isNative, startLive } from "@/lib/nativescan";
import { scanReject, scanTick } from "@/lib/feedback";
import { track } from "@/lib/analytics";
import { useKeepAwake } from "@/lib/wakelock";
import { SHELF_LABEL_PREFIX } from "@/lib/scanroute";
import { readCount, writeCount } from "@/lib/opencount";
import { GROUND, INK, MAction, MBody, MButton, MEmpty, MError, MRow, MRule, MSection, MSplit, MTop, MTopAction } from "@/components/m";
import { CountFigures, CountPanel, HandsFree, TypeCount } from "@/components/m/count/CountPanel";
import { lineTitle, useCountLines } from "@/components/m/count/lines";
const DEBOUNCE_MS = 900;
export default function MCounting() {
const { s } = useSnap();
const router = useRouter();
const locationId = String(useParams().id || "");
const { lines, locName, locs } = useCountLines(locationId);
const [counted, setCounted] = useState<Record<string, number>>({});
// Held by variant key, never by position: the list is rebuilt on every live refresh.
const [activeKey, setActiveKey] = useState("");
const [single, setSingle] = useState(false);
const [hands, setHands] = useState(false);
// "inline": MLKit behind this screen. "overlay": the live camera overlay (browser, or a shell without MLKit).
const [handsMode, setHandsMode] = useState<"inline" | "overlay">("inline");
const [typing, setTyping] = useState(false);
const [log, setLog] = useState<string[]>([]);
const [err, setErr] = useState("");
const [loaded, setLoaded] = useState(false);
// Restore this person's open count of this shelf, once per shelf. The key includes the user: the
// phone is shared. It deliberately does not re-run on `lines`, so a size moved off the shelf from
// the desktop mid-count keeps what was already counted against it.
const me = s.session.userId;
useEffect(() => {
setCounted(readCount(me, locationId)?.n ?? {});
// Recount from Check the gaps lands here with ?line=<key>.
try {
const line = new URLSearchParams(window.location.search).get("line");
if (line) setActiveKey(line);
} catch { /* no query to read */ }
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 curName = cur ? `${lineTitle(cur)} · ${cur.size}` : "";
const curN = cur ? counted[cur.key] ?? 0 : 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();
if (!code) return;
const hit = s.barcodes[code];
const line = hit ? lines.find((l) => l.key === hit) : lines.find((l) => l.code === code);
if (!line) {
scanReject();
const known = Object.prototype.hasOwnProperty.call(s.barcodes, code);
track("scan_miss", { kind: known ? "wrong_shelf" : "unknown" });
const shelf = code.startsWith(SHELF_LABEL_PREFIX) ? locs[code.slice(SHELF_LABEL_PREFIX.length)] : undefined;
const placedAt = hit ? locs[s.placed[hit] || ""]?.name : "";
setErr(shelf ? `Thats the label for ${shelf.name}`
: !known ? `${code} isnt a garment ThreadCount knows`
: placedAt ? `${code} is on ${placedAt}, not this shelf`
: locationId === UNPLACED ? `${code} isnt in this count` : `${code} isnt on a shelf yet`);
setLog((g) => [`${code}: not on this shelf`, ...g].slice(0, 8));
return;
}
setActiveKey(line.key);
setTyping(false);
bump(line.key, 1);
setErr("");
setLog((g) => [`${lineTitle(line)} ${line.size}`, ...g].slice(0, 8));
}, [s.barcodes, s.placed, lines, locs, locationId, bump]);
const onCodeRef = useRef(onCode); onCodeRef.current = onCode;
// Hands-free inside the Android shell: MLKit reads continuously behind this screen. This screen's
// root carries `tcx-scanui` while it runs, so globals.css leaves it visible and opaque.
const [inlineLive, setInlineLive] = useState(false);
useEffect(() => {
if (!hands || handsMode !== "inline") return;
if (!isNative()) { setHandsMode("overlay"); return; }
let session: { stop: () => Promise<void> } | null = null;
let cancelled = false;
let lastRaw = "", lastT = 0;
setInlineLive(true);
(async () => {
const r = await startLive((raw) => {
if (raw === lastRaw && Date.now() - lastT < DEBOUNCE_MS) return;
lastRaw = raw; lastT = Date.now();
scanTick();
onCodeRef.current(raw);
});
if (cancelled) { await r.stop(); return; }
if (r.error === "native-unavailable") { setInlineLive(false); setHandsMode("overlay"); return; }
if (r.error) { setInlineLive(false); setHands(false); setErr(r.error); return; }
session = r;
})();
track("scan_opened", { mode: "live", engine: "mlkit-inline" });
return () => { cancelled = true; setInlineLive(false); if (session) session.stop(); };
}, [hands, handsMode]);
const toggleHands = () => {
setTyping(false);
setSingle(false);
setHands((h) => !h);
};
if (loaded && !lines.length) {
return (
<>
<MTop title={locName} back />
<MRule />
<MBody pad><MEmpty title="Nothing on this shelf" sub="Place sizes on it in the portal" /></MBody>
</>
);
}
const panelInner = cur ? <CountFigures name={curName} counted={curN} expected={cur.expected} /> : null;
return (
// display: contents keeps every piece a direct flex child of .tcx-app as before; the class is what
// spares this screen while MLKit's preview runs behind the WebView.
<div className={inlineLive ? "tcx-scanui" : undefined} style={{ display: "contents" }}>
<MTop title={locName} back right={<MTopAction label="Finish" onClick={() => { setHands(false); router.push(`/m/count/${locationId}/variance`); }} />} />
<MRule n={total} of={expectedAll} />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
{cur && (
<CountPanel>
{panelInner}
<HandsFree on={hands} onToggle={toggleHands} />
{typing && (
<TypeCount key={cur.key} name={curName} value={curN}
onSet={(n) => { setCounted((c) => ({ ...c, [cur.key]: n })); setTyping(false); }} />
)}
</CountPanel>
)}
<MSection label="Lines" right={`${total} of ${expectedAll}`} />
{lines.map((l) => {
const n = counted[l.key] ?? 0;
const on = !!cur && l.key === cur.key;
return (
<MRow key={l.key} active={on} onClick={() => { setActiveKey(l.key); setTyping(false); }}
mark={n === l.expected ? "ok" : n > 0 ? "ink" : "mute"}
title={`${lineTitle(l)} ${l.size}`}
sub={l.where || undefined}
right={`${n}/${l.expected}`} />
);
})}
<div style={{ marginTop: 14 }}>
<MButton label="Type a count" onClick={() => { setHands(false); setTyping((t) => !t); }} disabled={!cur} />
</div>
</MBody>
<MSplit>
<MAction label="Undo" flex={1} tone="ink" onClick={() => cur && bump(cur.key, -1)} disabled={!cur || curN <= 0} />
<MAction label="Scan" flex={2} glyph="scan" glyphAt="right" onClick={() => { setHands(false); setTyping(false); setSingle(true); }} />
</MSplit>
{single && (
<MScan title="Scan a garment" onHit={(raw) => { onCode(raw); setSingle(false); }} onClose={() => setSingle(false)} />
)}
{hands && handsMode === "overlay" && (
<MScan
title="Hands-free"
live
running
log={log}
debounceMs={DEBOUNCE_MS}
onHit={onCode}
onClose={() => setHands(false)}
figure={cur ? <div style={{ background: INK, color: GROUND, padding: "12px 16px 0" }}>{panelInner}</div> : undefined}
control={<div style={{ background: INK, padding: "0 16px calc(12px + env(safe-area-inset-bottom, 0px))" }}><HandsFree on onToggle={() => setHands(false)} /></div>}
/>
)}
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
/* Check the gaps: the lines that don't match, each with a reason, then the commit.
A gap at or over the facility's threshold (settings.varianceReason, enforced again by
stocktake.apply) must carry a reason before the bar will commit. The reason chosen is stored
on the stocktake line. */
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSnap } from "@/lib/client";
import { UNPLACED } from "@/lib/compute";
import { clearCount, readCount, writeCount } from "@/lib/opencount";
import { INK, MBar, MBody, MEmpty, MError, MKick, MLine, MPill, MReasonChips, MRow, MRule, MSection, MTop } from "@/components/m";
import { OVER_REASONS, SHORT_REASONS, lineTitle, signed, useCountLines } from "@/components/m/count/lines";
const chip: React.CSSProperties = {
minHeight: 44, minWidth: 48, padding: "0 12px", border: "2px solid " + INK, background: "transparent", color: INK,
fontFamily: "inherit", fontSize: 14, fontWeight: 700, cursor: "pointer", borderRadius: 0, flex: "none",
};
export default function MVariance() {
const { s, mutate, busy } = useSnap();
const router = useRouter();
const locationId = String(useParams().id || "");
const { lines, locName } = useCountLines(locationId);
const [counted, setCounted] = useState<Record<string, number> | null>(null);
const [reason, setReason] = useState<Record<string, string>>({});
const [err, setErr] = useState("");
const me = s.session.userId;
useEffect(() => {
setCounted(readCount(me, locationId)?.n ?? {});
}, [me, locationId]);
const gate = Math.max(1, s.settings.varianceReason);
const gaps = useMemo(() => (counted ? lines.filter((l) => (counted[l.key] ?? 0) !== l.expected) : []), [counted, lines]);
const matches = useMemo(() => (counted ? lines.filter((l) => (counted[l.key] ?? 0) === l.expected) : []), [counted, lines]);
const missing = gaps.filter((l) => Math.abs((counted?.[l.key] ?? 0) - l.expected) >= gate && !reason[l.key]);
const ready = missing.length === 0;
const recount = useCallback((key: string) => {
if (!counted) return;
writeCount(me, locationId, { ...counted, [key]: 0 });
setReason((r) => { const n = { ...r }; delete n[key]; return n; });
router.push(`/m/count/${locationId}?line=${encodeURIComponent(key)}`);
}, [counted, me, locationId, router]);
const commit = useCallback(async () => {
if (!counted || !ready || busy) return;
const payload = lines.map((l) => {
const n = counted[l.key] ?? 0;
return { itemId: l.itemId, si: l.si, counted: n, reason: n !== l.expected ? 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);
router.replace(`/m?flash=counted&loc=${encodeURIComponent(locName)}&gaps=${gaps.length}`);
}, [counted, ready, busy, lines, reason, mutate, locationId, me, router, locName, gaps.length]);
if (!counted) return (<><MTop title="Check the gaps" back /><MRule /><MBody pad /></>);
return (
<>
<MTop title="Check the gaps" back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
<MKick>{locName}</MKick>
<h2 style={{ fontSize: 26, fontWeight: 900, margin: "2px 0 0", lineHeight: 1.15 }}>
{gaps.length === 0 ? "Everything matches" : `${gaps.length} gap${gaps.length === 1 ? "" : "s"}`}
</h2>
{lines.length === 0 && <MEmpty title="Nothing on this shelf" />}
{gaps.map((l) => {
const n = counted[l.key] ?? 0;
const d = n - l.expected;
const name = `${lineTitle(l)} ${l.size}`;
return (
<div key={l.key} style={{ marginTop: 10 }}>
<MLine title={name} flag={`${n} counted, ${l.expected} expected`}
right={
<>
<MPill tone="accent" mono>{signed(d)}</MPill>
<button type="button" style={chip} onClick={() => recount(l.key)} aria-label={`Recount ${name}`}>Recount</button>
</>
}>
<MReasonChips reasons={d > 0 ? OVER_REASONS : SHORT_REASONS} value={reason[l.key] || null} label={`Reason for ${name}`}
onPick={(r) => setReason((x) => { const o = { ...x }; if (r) o[l.key] = r; else delete o[l.key]; return o; })} />
</MLine>
</div>
);
})}
{matches.length > 0 && (
<div style={{ marginTop: 22 }}>
<MSection label="Match" right={matches.length} />
{matches.map((l) => (
<MRow key={l.key} dense mark="ok" title={`${lineTitle(l)} ${l.size}`} right={counted[l.key] ?? 0} />
))}
</div>
)}
</MBody>
<MBar label={busy ? "Committing…" : "Commit count"}
small={ready ? `${lines.length} line${lines.length === 1 ? "" : "s"}` : "reason each gap"}
onClick={commit} disabled={!ready || busy || lines.length === 0}
offReason={!ready ? "Pick a reason for each gap" : undefined} />
</>
);
}
+1
View File
@@ -0,0 +1 @@
export { default } from "@/components/m/stock/CountList";
+27
View File
@@ -0,0 +1,27 @@
"use client";
/* The Done screen as its own history entry. A finished hand-over or delivery router.replace()s its
form here (components/SignFlow showDone), so Back skips the spent form, a router refresh re-renders
this screen rather than the form's parent, and a reload still shows what was just recorded. */
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { DoneScreen, readDone, type DoneProps } from "@/components/SignFlow";
import { MBody, MKick, MRule, MTop } from "@/components/m";
export default function DonePage() {
const router = useRouter();
const [done, setDone] = useState<DoneProps | null>(null);
useEffect(() => {
const d = readDone();
if (d) setDone(d);
else router.replace("/m");
}, [router]);
if (done) return <DoneScreen {...done} />;
return (
<>
<MTop title="Done" />
<MRule />
<MBody pad><MKick>Loading</MKick></MBody>
</>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
/* The person's record opens on its Issue segment. */
export default async function IssueTo({ params }: { params: Promise<{ staffId: string }> }) {
const { staffId } = await params;
redirect(`/m/person/${encodeURIComponent(staffId)}`);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/* Issuing starts from a person now: the People tab, or a scanned badge. */
export default function IssuePicker() {
redirect("/m/people");
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/* Reprinting a label is Print a label on the stock line's own page. */
export default function Label() {
redirect("/m/stock?seg=all");
}
+28
View File
@@ -0,0 +1,28 @@
import { redirect } from "next/navigation";
import { currentUser } from "@/lib/session";
import { buildSnapshot } from "@/lib/snapshot";
import { SnapshotProvider } from "@/lib/client";
import { MToastProvider } from "@/components/m";
import { MBasketProvider } from "@/components/MBasket";
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.
The toast and the in-progress basket sit inside the snapshot, and neither renders a wrapper
element, so every screen stays a direct child of .tcx-app (the native scan transparency relies
on that). */
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}>
<MToastProvider>
<MBasketProvider>{children}</MBasketProvider>
</MToastProvider>
</SnapshotProvider>
);
}
+98
View File
@@ -0,0 +1,98 @@
"use client";
/* One stock line: a garment in one size. On hand against par, where it lives, what is coming, and the
three things done about it: print a label, count its shelf, put it on the draft order. */
import { useCallback, useState } from "react";
import { useParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { bcBound, key, label, locMap, locTrail, money, onOrderText, onhand, reorderAt } from "@/lib/compute";
import { INK, MBody, MButton, MEmpty, MKick, MONO, MPill, MRow, MRule, MTop, useToast } from "@/components/m";
import PrintSheet from "@/components/m/stock/PrintSheet";
import { heldByStaff, lastCountedText } from "@/components/m/stock/stockdata";
const dt: React.CSSProperties = { margin: 0, padding: "11px 14px 11px 0", borderBottom: "1px solid var(--color-divider)", fontSize: 12, fontWeight: 800, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-neutral-600)" };
const dd: React.CSSProperties = { margin: 0, padding: "11px 0", borderBottom: "1px solid var(--color-divider)", textAlign: "right", fontWeight: 700 };
export default function MLinePage() {
const params = useParams<{ itemId: string; si: string }>();
const { s, isAdmin, mutate, busy } = useSnap();
const { L } = useDerived();
const toast = useToast();
const [printing, setPrinting] = useState(false);
// Stable, because MSheet re-runs its focus handling whenever onClose changes.
const closePrint = useCallback(() => setPrinting(false), []);
const itemId = decodeURIComponent(String(params.itemId || ""));
const si = /^\d+$/.test(String(params.si || "")) ? Number(params.si) : -1;
const it = s.catalog.find((x) => x.id === itemId);
if (!it || it.archived || si < 0 || si >= it.sizes.length) {
return (
<>
<MTop title="Stock line" back />
<MRule />
<MBody pad><MEmpty title="No such line" sub="It may have been archived." /></MBody>
</>
);
}
const k = key(it.id, si);
const size = String(it.sizes[si]);
const oh = onhand(s, L, k), par = reorderAt(s, k);
const code = bcBound(s, it, si);
const locs = locMap(s);
const placedId = s.placed[k];
const placed = placedId ? locs[placedId] : undefined;
const shelf = placed ? (locTrail(locs, placed.id, 0) || placed.name) : "";
const onOrder = onOrderText(s, it.id, si);
const short = Math.max(0, par - oh);
const addToDraft = async () => {
const r = await mutate<{ added: number; qty: number }>("stock.orderLine", { itemId: it.id, si });
if (!r.ok) { toast(r.error); return; }
toast(r.result.added > 0 ? `Added ${r.result.added} to the draft order` : "Already on the draft order");
};
return (
<>
<MTop title={`${it.item} ${size}`} back />
<MRule />
<MBody pad>
<MKick mono>{code || (it.sku ? `SKU ${it.sku}` : "No barcode")}</MKick>
<h2 style={{ fontSize: 24, fontWeight: 900, margin: "2px 0 0", lineHeight: 1.1 }}>{label(it)} · {size}</h2>
<div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", gap: 12, marginTop: 14, borderBottom: "2px solid " + INK, paddingBottom: 12 }}>
<div style={{ fontSize: 64, fontWeight: 900, lineHeight: 0.95, letterSpacing: "-0.03em", fontVariantNumeric: "tabular-nums" }}>
{oh}<small style={{ fontSize: 22, fontWeight: 700, color: "var(--color-neutral-600)", letterSpacing: 0 }}> / {par} par</small>
</div>
{short > 0
? <MPill tone={oh <= 0 ? "accent" : "ink"}>{short} short</MPill>
: <MPill tone="ok">At par</MPill>}
</div>
<dl style={{ display: "grid", gridTemplateColumns: "auto 1fr", margin: "8px 0 0" }}>
<dt style={dt}>Shelf</dt><dd style={dd}>{shelf || "Not on a shelf"}</dd>
<dt style={dt}>On order</dt><dd style={dd}>{onOrder || "None"}</dd>
<dt style={dt}>Last counted</dt><dd style={dd}>{lastCountedText(s, it.id, si)}</dd>
<dt style={dt}>Held by staff</dt><dd style={{ ...dd, fontFamily: MONO }}>{heldByStaff(s, it.id, si)}</dd>
<dt style={dt}>Unit cost</dt><dd style={{ ...dd, fontFamily: MONO }}>{money(it.cost)}</dd>
</dl>
<div style={{ marginTop: 4 }}>
<MButton tone="ink" icon="print" label="Print a label" disabled={!code} onClick={() => setPrinting(true)} />
{!code && <div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 6 }}>No barcode bound</div>}
{!code && isAdmin && <MButton label="Bind a barcode" href={`/m/catalogue/${encodeURIComponent(it.id)}`} />}
{placed && <MButton label={`Count ${shelf}`} href={`/m/count/${encodeURIComponent(placed.id)}?line=${encodeURIComponent(k)}`} />}
<MButton label={busy ? "Adding…" : "Add to the draft order"} onClick={addToDraft} disabled={busy} />
</div>
{isAdmin && (
<div style={{ marginTop: 14 }}>
<MRow chev href={`/m/catalogue/${encodeURIComponent(it.id)}`} title="Product card" />
</div>
)}
</MBody>
{code && <PrintSheet open={printing} onClose={closePrint} code={code} title={`${label(it)} ${size}`} />}
</>
);
}
+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>
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/* More's rows moved: Settings is the gear on Today, the rest live in Stock and Work. */
export default function More() {
redirect("/m");
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
/* Today: what is waiting, what this counter has done today, and what just happened. */
import { Suspense } from "react";
import { MBody, MDay, MEmpty, MKick, MRow, MRule, MSection, MTabs, MTodo, MTop, MTopAction } from "@/components/m";
import TodayBanner from "@/components/m/today/TodayBanner";
import { useToday } from "@/components/m/today/useToday";
export default function MToday() {
const t = useToday();
return (
<>
<MTop title="Today" right={<MTopAction icon="gear" label="Settings" ariaLabel="Settings" href="/m/settings" />} />
<MRule />
<MBody pad>
<Suspense fallback={null}><TodayBanner /></Suspense>
<MKick>{t.kicker}</MKick>
<MSection label="To do" />
{t.todo.length === 0
? <MEmpty title="Nothing waiting" />
: t.todo.map((r) => <MTodo key={r.key} n={r.n} accent={r.accent} title={r.title} sub={r.sub} href={r.href} />)}
<MSection label="Your day" />
<MDay figs={[{ n: t.day.issued, label: "Issued" }, { n: t.day.back, label: "Handed back" }, { n: t.day.counted, label: "Shelves counted" }]} />
<MSection label="Recent" />
{t.recent.length === 0
? <MEmpty title="Nothing yet today" />
: t.recent.map((r) => <MRow key={r.key} mark="mute" title={r.title} sub={r.sub} right={r.right} href={r.href} />)}
</MBody>
<MTabs active="today" />
</>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
/* The People tab: the staff register, served-recently first, each row showing how near they are to
the sets one person holds (capCheck, the same check the counter uses). */
import { useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useSnap } from "@/lib/client";
import { capCheck, staffName } from "@/lib/compute";
import { MBody, MEmpty, MRow, MRule, MSearch, MSection, MTabs, MTop, MTopCount } from "@/components/m";
export default function People() {
const { s } = useSnap();
const sp = useSearchParams();
const [q, setQ] = useState(() => (sp.get("q") || "").slice(0, 80));
const active = useMemo(() => s.staff.filter((x) => !x.inactive), [s]);
const list = useMemo(() => {
const needle = q.trim().toLowerCase();
if (!needle) {
const seen: Record<string, string> = {};
for (const i of s.issues) if (i.date > (seen[i.staffId] || "")) seen[i.staffId] = i.date;
return [...active].sort((a, b) => (seen[b.id] || "").localeCompare(seen[a.id] || "") || staffName(a).localeCompare(staffName(b))).slice(0, 12);
}
return s.staff
.filter((x) => `${x.first} ${x.last} ${x.num} ${x.dept} ${x.group}`.toLowerCase().includes(needle))
.sort((a, b) => Number(a.inactive) - Number(b.inactive) || staffName(a).localeCompare(staffName(b)))
.slice(0, 40);
}, [s, q, active]);
return (
<>
<MTop title="People" right={<MTopCount>{active.length}</MTopCount>} />
<MRule />
<MBody pad>
<MSearch value={q} onChange={setQ} placeholder="Name or staff number" label="Search the staff register" scanHref="/m/scan" />
{s.staff.length === 0 ? (
<MEmpty title="No staff yet" sub="Staff are added in the portal." />
) : (
<>
<MSection label={q.trim() ? "Matches" : "Served recently"} right={list.length} />
{list.map((st) => {
const cap = capCheck(s, st);
const most = Math.max(cap.tops, cap.pants);
return (
<MRow key={st.id} href={`/m/person/${st.id}`} title={staffName(st)}
sub={[st.group, st.num, st.dept].filter((x) => x && x.trim()).join(" · ")}
right={`${most}/${cap.cap}`} mark={st.inactive ? "mute" : most >= cap.cap ? "accent" : "ink"} />
);
})}
{q.trim() && list.length === 0 && <MEmpty title={`No one matches “${q.trim()}`} sub="Check the spelling, or scan their badge." />}
</>
)}
</MBody>
<MTabs active="people" />
</>
);
}
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
/* A size exchange is Swap size on a Hand back line. */
export default async function ExchangeFor({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
redirect(`/m/person/${encodeURIComponent(id)}?tab=back`);
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
/* The person screen is the issue screen: who they are, how near the six sets they hold, and the
* Issue | Hand back | History segments. Each segment draws its own docked bar through setBar; a
* finished hand back swaps the whole screen for the Done screen in place. */
import { useCallback, useRef, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import { useSnap } from "@/lib/client";
import { approvalRemaining, capCheck, isNursing, staffName } from "@/lib/compute";
import { MBody, MEmpty, MError, MHead, MHeadRow, MMeterPair, MPill, MRule, MSeg, MTop } from "@/components/m";
import { useBasket } from "@/components/MBasket";
import { DoneScreen, type DoneProps } from "@/components/SignFlow";
import HandBackTab from "@/components/person/HandBackTab";
import IssueTab from "@/components/m/issue/IssueTab";
import HistoryTab from "@/components/m/issue/HistoryTab";
import { personMeta, plural } from "@/components/m/issue/meta";
type Tab = "issue" | "back" | "history";
const TABS: { key: Tab; label: string }[] = [
{ key: "issue", label: "Issue" },
{ key: "back", label: "Hand back" },
{ key: "history", label: "History" },
];
const asTab = (v: string | null): Tab => (v === "back" || v === "history" ? v : "issue");
export default function MPersonPage() {
const { s } = useSnap();
const id = String(useParams().id || "");
const sp = useSearchParams();
const basket = useBasket();
const st = s.staff.find((x) => x.id === id);
const [tab, setTab] = useState<Tab>(() => asTab(sp.get("tab")));
const [bar, setBarNode] = useState<React.ReactNode>(null);
const [err, setErr] = useState("");
const [done, setDone] = useState<DoneProps | null>(null);
const top = useRef<HTMLSpanElement | null>(null);
const setBar = useCallback((b: React.ReactNode) => setBarNode(b), []);
const onError = useCallback((m: string) => setErr(m), []);
const onDone = useCallback((d: DoneProps) => { setErr(""); setDone(d); }, []);
if (done) return <DoneScreen {...done} />;
if (!st) {
return (
<>
<MTop title="Person" back />
<MRule />
<MBody pad><MEmpty title="No such staff member" /></MBody>
</>
);
}
const pickTab = (k: Tab) => {
if (k === tab) return;
setErr("");
setTab(k);
try { window.history.replaceState(null, "", `/m/person/${encodeURIComponent(id)}${k === "issue" ? "" : `?tab=${k}`}`); } catch { /* not fatal */ }
const body = top.current?.parentElement;
if (body) body.scrollTop = 0;
};
const lines = basket.issue(id);
const cap = capCheck(s, st, lines);
const left = approvalRemaining(s, st.id);
const props = { staffId: st.id, setBar, onDone, onError };
return (
<>
<MTop title={staffName(st)} back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
<span ref={top} aria-hidden="true" style={{ display: "block", height: 0 }} />
<MHead name={staffName(st)} meta={personMeta(s, st)}>
{tab === "issue" && (
<>
<MMeterPair items={[
{ label: "Tops held", held: cap.tops, adding: cap.addTops, cap: cap.cap },
{ label: "Pants held", held: cap.pants, adding: cap.addPants, cap: cap.cap },
]} />
{isNursing(s, st) && <MHeadRow label="Manager approval" value={`${plural(left, "set")} left`} />}
</>
)}
</MHead>
{st.inactive && <div style={{ margin: "-4px 0 10px" }}><MPill tone="accent">Inactive</MPill></div>}
<MSeg label="Record" value={tab} options={TABS} onPick={pickTab} />
{tab === "issue" ? <IssueTab {...props} /> : tab === "back" ? <HandBackTab {...props} /> : <HistoryTab {...props} />}
</MBody>
{bar}
</>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
/* Returns are the Hand back segment of the person's record. */
export default async function ReturnFrom({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
redirect(`/m/person/${encodeURIComponent(id)}?tab=back`);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
/* Sign (issue): what is being handed over, the manager approval deduction, their signature and the
* slip switch. issue.create records the lines, the per-line reasons, the approval deduction and the
* signed slip in one locked write; SignFlow then shows the Done screen in place. */
import { useState } from "react";
import { useParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { approvalRemaining, isNursing, isPantItem, isTopItem, issueLineFlags, label, staffName } from "@/lib/compute";
import { MBody, MButton, MEmpty, MHead, MRow, MRule, MSection, MStepper, MTop } from "@/components/m";
import { useBasket, type IssueLine } from "@/components/MBasket";
import SignFlow from "@/components/SignFlow";
import { personMeta, plural } from "@/components/m/issue/meta";
export default function SignIssue() {
const { s, mutate } = useSnap();
const { byId } = useDerived();
const id = String(useParams().id || "");
const basket = useBasket();
const st = s.staff.find((x) => x.id === id);
// Once issued the basket is cleared; the lines are kept here so the screen does not fall back to
// "nothing to issue" in the moment before the Done screen replaces it.
const [frozen, setFrozen] = useState<IssueLine[] | null>(null);
const lines = frozen ?? basket.issue(id);
if (!st || !lines.length) {
return (
<>
<MTop title="Sign" back />
<MRule />
<MBody pad>
<MEmpty title={st ? "Nothing to issue" : "No such staff member"} />
{st && <MButton label="Back to their record" href={`/m/person/${encodeURIComponent(st.id)}`} />}
</MBody>
</>
);
}
const n = lines.reduce((t, l) => t + l.qty, 0);
const tops = lines.reduce((t, l) => t + (isTopItem(byId[l.itemId]) ? l.qty : 0), 0);
const pants = lines.reduce((t, l) => t + (isPantItem(byId[l.itemId]) ? l.qty : 0), 0);
const sets = Math.max(tops, pants);
const left = approvalRemaining(s, st.id);
const approval = isNursing(s, st) && left > 0;
const most = Math.min(left, sets);
const chosen = basket.deduct(id);
const deduct = Math.max(0, Math.min(most, chosen ?? most));
const extra = approval ? (
<>
<MSection label="Manager approval" right={`${plural(left, "set")} left`} />
<MRow title="Take off the approval" sub={`This issue is ${plural(sets, "set")}`}
right={<MStepper label="sets off the approval" n={deduct} min={0} max={most} onChange={(v) => basket.setDeduct(id, v)} />} />
</>
) : undefined;
const commit = async ({ sigId, slip }: { sigId: string; slip: boolean }) => {
const flags = issueLineFlags(s, st, lines);
const r = await mutate<{ slipId: string | null }>("issue.create", {
staffId: st.id,
lines: lines.map((l, i) => ({ itemId: l.itemId, si: l.si, qty: l.qty, src: "stock", reason: flags[i] ? l.reason || "" : "" })),
override: flags.some(Boolean),
lineReasons: true,
apDeduct: approval ? deduct : 0,
sigId,
slip,
});
if (!r.ok) return { ok: false as const, error: r.error };
const sent = lines;
setFrozen(sent);
basket.clear("issue", st.id);
return {
ok: true as const,
done: {
head: `${plural(n, "item")} issued`,
sub: `${staffName(st)} · ${slip ? "slip sent to their staff app" : "signed"}`,
shelfKeys: sent.map((l) => l.key),
next: "scan" as const,
},
};
};
return (
<SignFlow
kind="issue"
head={<MHead name={staffName(st)} meta={personMeta(s, st)} />}
lines={lines.map((l) => ({ key: l.key, name: `${label(byId[l.itemId])} ${String(byId[l.itemId]?.sizes[l.si] ?? l.si)}`, qty: l.qty }))}
signerName={staffName(st)}
extra={extra}
slip={{ available: !!st.selfEmail }}
barLabel={`Issue ${plural(n, "item")}`}
commit={commit}
/>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/* The pickup call list is Work Pickups. */
export default function Pickups() {
redirect("/m/work?seg=pickups");
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
/* Receive a delivery: tick each line as it is unpacked, step a line down when less came. Receiving
closes the order; anything short goes onto a back order raised by order.receive. */
import { useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { key, staffName } from "@/lib/compute";
import { plural } from "@/lib/today";
import { DoneScreen, useShowDone, type DoneProps } from "@/components/SignFlow";
import { MBar, MBody, MButton, MEmpty, MError, MField, MKick, MPickRow, MRule, MSection, MStepper, MTop, inputStyle } from "@/components/m";
import { RECEIVABLE, garmentSize, locMap, orderWhen, outstandingLines, segment, shelfOf, type OutLine } from "@/components/m/work/util";
export default function ReceiveOrder() {
const id = segment(useParams<{ id: string }>().id);
const { s, mutate } = useSnap();
const { byId, staffById } = useDerived();
const order = s.orders.find((o) => o.id === id);
const lines = useMemo(() => (order && RECEIVABLE.includes(order.status) ? outstandingLines(order, byId) : []), [order, byId]);
const locs = useMemo(() => locMap(s), [s]);
const [tick, setTick] = useState<Record<string, boolean>>({});
const [qty, setQty] = useState<Record<string, number>>({});
const [invoice, setInvoice] = useState(() => order?.invoice || "");
const [err, setErr] = useState("");
const [saving, setSaving] = useState(false);
const [done, setDone] = useState<DoneProps | null>(null);
const showDone = useShowDone();
if (done) return <DoneScreen {...done} />;
const q = (l: OutLine) => qty[l.id] ?? l.outstanding;
const ticked = lines.filter((l) => tick[l.id]);
const n = ticked.reduce((t, l) => t + q(l), 0);
const all = lines.length > 0 && ticked.length === lines.length;
const receive = async () => {
if (!order || saving) return;
setSaving(true);
setErr("");
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), dest: order.staffId ? "pickup" : "shelf" })),
});
setSaving(false);
if (!r.ok) { setErr(r.error); return; }
const short = lines.some((l) => q(l) < l.outstanding);
const d: DoneProps = {
head: `${plural(n, "item")} received`,
sub: `${order.code} · ${order.supplier || "Supplier"} · order closed${short ? " · back order raised" : ""}`,
shelfKeys: order.staffId ? [] : lines.filter((l) => q(l) > 0 && l.si >= 0).map((l) => key(l.itemId, l.si)),
next: "work",
};
setDone(d);
showDone(d);
};
const forName = order?.staffId ? staffName(staffById[order.staffId]) : "";
return (
<>
<MTop title={order?.code || "Receive"} back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
{!order || !lines.length ? (
<>
<MEmpty title="Nothing outstanding" />
<MButton label="Back to Work" href="/m/work?seg=in" />
</>
) : (
<>
<MKick>{`${order.supplier || "Supplier"} · ${orderWhen(s, order)}`}</MKick>
<MSection label="Tick what came in" right={`${ticked.length} of ${lines.length}`} />
{lines.map((l) => {
const name = garmentSize(byId[l.itemId], "Garment", l.size);
const sub = forName ? `for ${forName}` : l.si >= 0 ? shelfOf(s, locs, l.itemId, l.si) : "";
return (
<MPickRow key={l.id} done={!!tick[l.id]} onToggle={() => setTick((x) => ({ ...x, [l.id]: !x[l.id] }))} title={name} sub={sub || undefined} tickLabel={`Came in ${name}`}>
<MStepper n={q(l)} min={0} max={l.outstanding} label={name} onChange={(v) => setQty((x) => ({ ...x, [l.id]: v }))} />
</MPickRow>
);
})}
<MButton label="Tick all" onClick={() => setTick(Object.fromEntries(lines.map((l) => [l.id, true])))} />
<MField label="Invoice number">
<input value={invoice} onChange={(e) => setInvoice(e.target.value)} autoComplete="off" maxLength={80} style={inputStyle} />
</MField>
</>
)}
</MBody>
{order && lines.length > 0 && (
<MBar label={saving ? "Recording…" : `Receive ${plural(n, "item")}`} disabled={saving || !all || n === 0}
offReason={saving ? undefined : !all ? "Tick each line" : "Nothing arrived"} onClick={receive} />
)}
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/* Deliveries to receive are Work In; each opens /m/receive/[id]. */
export default function Receive() {
redirect("/m/work?seg=in");
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
/* Draft order: what fell below par, at quantities that bring each line back up. Raising it makes a
draft on Ordering; nothing reaches a supplier until someone approves it there. */
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { flaggedNeeds, label, onhand, reorderAt, touched } from "@/lib/compute";
import { MBar, MBody, MButton, MDone, MEmpty, MError, MRow, MRule, MSection, MStepper, MTop, MTopCount } from "@/components/m";
export default function MReorder() {
const { s, mutate, busy } = useSnap();
const { L, byId, variants } = useDerived();
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: `${label(byId[n.itemId])} ${n.size}`, oh: onhand(s, L, k), par: reorderAt(s, k) };
}), [s, L, byId]);
// Stock's "short" counts every line at or under par; flaggedNeeds drops the ones open orders cover.
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 = Math.max(0, 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="Done" />
<MRule />
<MBody pad>
<MDone head={`Draft ${done} raised`} sub="Waiting on Ordering" />
<MButton label="Back to Stock" href="/m/stock" />
</MBody>
</>
);
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
return (
<>
<MTop title="Draft order" back right={<MTopCount>{plural(needs.length, "line")}</MTopCount>} />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
{needs.length === 0 ? (
<MEmpty title={belowPar ? "Already on order" : "Nothing to reorder"}
sub={belowPar ? `${plural(belowPar, "line")} short, all on open orders` : "Every line is above par"} />
) : (
<>
<MSection label="To order" right={suppliers.length === 1 ? suppliers[0] : plural(suppliers.length, "supplier")} />
{needs.map((n) => (
<MRow key={n.key} mark={n.oh <= 0 ? "accent" : "ink"} title={n.name}
sub={`${n.oh}/${n.par} · ${n.supplier}`}
right={<MStepper n={q(n.key, n.qty)} onChange={(v) => setQty((x) => ({ ...x, [n.key]: v }))} label={n.name} />} />
))}
</>
)}
{covered > 0 && needs.length > 0 && (
<>
<MSection label="Already on order" right={covered} />
<MRow mark="mute" chev href="/m/stock?seg=order" title="On order" right={covered} />
</>
)}
</MBody>
{needs.length > 0 && (
<MBar label={busy ? "Raising…" : "Raise the draft"} small={plural(total, "item")} onClick={raise}
disabled={busy || total === 0} offReason={total === 0 ? "Set a quantity on at least one line" : undefined} />
)}
</>
);
}
+135
View File
@@ -0,0 +1,135 @@
"use client";
/* Pick an approved staff request: tick or scan each garment in the bag, then hand it over. The first
pick moves the request to "picking" through request.pick, so the desktop queue shows it as being
picked. Ticks live in the basket (this phone only) until the hand-over is signed. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { wasSpent } from "@/components/SignFlow";
import { useDerived, useSnap } from "@/lib/client";
import { bcParse, key, onhand } from "@/lib/compute";
import { scanReject } from "@/lib/feedback";
import { relativeDay } from "@/lib/today";
import { PICK_STATUSES } from "@/lib/workcount";
import { useRequests } from "@/components/requests/RequestList";
import { useBasket } from "@/components/MBasket";
import MScan from "@/components/MScan";
import {
MBar, MBody, MButton, MEmpty, MError, MHead, MKick, MPickRow, MRow, MRule, MSection, MTop, useScanFlash, useToast,
} from "@/components/m";
import { garmentSize, locMap, personMeta, segment, shelfOf } from "@/components/m/work/util";
export default function PickRequest() {
const id = segment(useParams<{ id: string }>().id);
const { s, mutate } = useSnap();
const { L, byId, staffById } = useDerived();
const { data, error, reload } = useRequests();
const basket = useBasket();
const toast = useToast();
const flash = useScanFlash();
const [err, setErr] = useState("");
const [scanning, setScanning] = useState(false);
const pickFired = useRef(false);
const locs = useMemo(() => locMap(s), [s]);
const r = data?.requests.find((x) => x.id === id);
const lines = useMemo(() => r?.bag ?? [], [r]);
const stored = basket.picked(id);
const router = useRouter();
const gone = !!data && (!r || !PICK_STATUSES.has(r.status) || !lines.length);
const spent = gone && wasSpent(`/m/request/${id}`);
// Back from this request's own Done screen: the list is spent, so carry on to the queue.
useEffect(() => { if (spent) router.replace("/m/work?seg=picks"); }, [spent, router]);
// A ready bag was packed at the counter already: every line starts ticked.
const got = useCallback((lineId: string, q: number) => {
const v = stored[lineId];
if (v === undefined) return r?.status === "ready" ? q : 0;
return Math.max(0, Math.min(q, v));
}, [stored, r]);
const total = lines.reduce((t, l) => t + l.qty, 0);
const picked = lines.reduce((t, l) => t + got(l.id, l.qty), 0);
const setLine = (lineId: string, n: number) => {
const prev = stored;
basket.setPicked(id, { ...prev, [lineId]: n });
return () => basket.setPicked(id, prev);
};
/** The first garment picked off an approved request tells the queue it is being picked. */
const firstPick = async (undo: () => void) => {
if (!r || r.status !== "accepted" || pickFired.current) return;
pickFired.current = true;
const res = await mutate("request.pick", { id: r.id });
if (!res.ok) { pickFired.current = false; undo(); setErr(res.error); void reload(); return; }
void reload();
};
const toggle = (lineId: string, q: number) => {
const cur = got(lineId, q);
const undo = setLine(lineId, cur >= q ? 0 : q);
if (cur < q) void firstPick(undo);
};
const onScan = (raw: string) => {
setScanning(false);
const hit = bcParse(s, raw);
if (!hit) { scanReject(); toast("That code isnt a garment"); return; }
const it = byId[hit.itemId];
const name = garmentSize(it, "Garment", it?.sizes[hit.si] ?? hit.si);
const match = lines.filter((l) => l.itemId === hit.itemId && l.si === hit.si);
if (!match.length) { scanReject(); toast(`${name} isnt on this request`); return; }
const next = match.find((l) => got(l.id, l.qty) < l.qty);
if (!next) { toast("Everything is picked"); return; }
const n = got(next.id, next.qty) + 1;
flash("Garment", name, () => { const undo = setLine(next.id, n); void firstPick(undo); });
};
const shell = (body: React.ReactNode, bar?: React.ReactNode) => (
<>
<MTop title="Pick request" back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>{body}</MBody>
{bar}
</>
);
if (!data) {
return shell(error
? <><MError msg="Requests couldnt be loaded." /><MButton small label="Try again" onClick={() => void reload()} /></>
: <MKick>Loading</MKick>);
}
if (!r || !PICK_STATUSES.has(r.status) || !lines.length) {
if (spent) return shell(<MKick>Loading</MKick>);
return shell(<><MEmpty title="That request has moved on" /><MButton label="Back to Work" href="/m/work?seg=picks" /></>);
}
const st = staffById[r.staffId];
const when = relativeDay(r.decidedAt, s);
return (
<>
{shell(
<>
<MHead name={r.staffName} meta={personMeta(s, st, r.ward)} />
<MButton tone="ink" icon="scan" label="Scan to pick"
onClick={() => (picked >= total ? toast("Everything is picked") : setScanning(true))} />
<MSection label="Pick list" right={`${picked} of ${total}`} />
{lines.map((l, i) => {
const n = got(l.id, l.qty);
const onShelf = Math.max(0, onhand(s, L, key(l.itemId, l.si)));
const sub = [shelfOf(s, locs, l.itemId, l.si), `${onShelf} on shelf`, r.status === "ready" && i === 0 && r.collectCode ? `code ${r.collectCode}` : ""]
.filter(Boolean).join(" · ");
return (
<MPickRow key={l.id} done={n >= l.qty} onToggle={() => toggle(l.id, l.qty)}
title={garmentSize(byId[l.itemId], l.item, l.size)} sub={sub} right={`${n}/${l.qty}`} />
);
})}
<MSection label="Approved" />
<MRow mark="ok" title={r.managerName || "Manager"} sub={when ? `Approved ${when}` : "Approved"} />
</>,
<MBar label="Hand over" small={`${picked} of ${total} picked`} disabled={picked < total}
offReason="Pick every line first" href={`/m/request/${r.id}/sign`} />,
)}
{scanning && <MScan title="Scan to pick" onHit={onScan} onClose={() => setScanning(false)} />}
</>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
/* Sign for a picked request, then Done. A request still "accepted" is moved to "picking" first
(the queue only lets a picked request be collected); request.collected writes the issue rows, the
signature and, when asked, the slip to the wearer's staff app. */
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { key } from "@/lib/compute";
import { plural } from "@/lib/today";
import { PICK_STATUSES } from "@/lib/workcount";
import { useRequests, type RequestRow } from "@/components/requests/RequestList";
import { useBasket } from "@/components/MBasket";
import SignFlow from "@/components/SignFlow";
import { MBody, MButton, MEmpty, MError, MHead, MKick, MRule, MTop } from "@/components/m";
import { garmentSize, personMeta, segment } from "@/components/m/work/util";
export default function RequestSign() {
const id = segment(useParams<{ id: string }>().id);
const { s, mutate } = useSnap();
const { byId, staffById } = useDerived();
const { data, error, reload } = useRequests();
const basket = useBasket();
// Held once loaded: the hand-over refreshes the queue, and the Done screen must not turn into
// "moved on" when this request leaves it.
const [held, setHeld] = useState<{ r: RequestRow; ready: boolean } | null>(null);
const [seen, setSeen] = useState(false);
const stored = basket.picked(id);
useEffect(() => {
if (held || !data) return;
const r = data.requests.find((x) => x.id === id);
if (r && PICK_STATUSES.has(r.status) && r.bag.length) {
const ready = r.status === "ready" || r.bag.every((l) => (stored[l.id] ?? 0) >= l.qty);
setHeld({ r, ready });
}
setSeen(true);
}, [data, id, held, stored]);
const shell = (body: React.ReactNode) => (
<>
<MTop title="Sign" back />
<MRule />
<MBody pad>{body}</MBody>
</>
);
if (!held) {
if (!data && error) return shell(<><MError msg="Requests couldnt be loaded." /><MButton small label="Try again" onClick={() => void reload()} /></>);
if (!data || !seen) return shell(<MKick>Loading</MKick>);
return shell(<><MEmpty title="That request has moved on" /><MButton label="Back to Work" href="/m/work?seg=picks" /></>);
}
const { r } = held;
if (!held.ready) {
return shell(<><MEmpty title="Pick every line first" /><MButton label="Back to the pick list" href={`/m/request/${r.id}`} /></>);
}
const st = staffById[r.staffId];
const total = r.bag.reduce((t, l) => t + l.qty, 0);
return (
<SignFlow
kind="request"
head={<MHead name={r.staffName} meta={personMeta(s, st, r.ward)} />}
lines={r.bag.map((l) => ({ key: l.id, name: garmentSize(byId[l.itemId], l.item, l.size), qty: l.qty }))}
signerName={r.staffName}
slip={{ available: !!st?.selfEmail }}
barLabel={`Hand over ${plural(total, "item")}`}
spentHref={`/m/request/${r.id}`}
commit={async ({ sigId, slip }) => {
// Accepted cannot go straight to collected. If the pick screen already moved it, this
// refusal is expected and collected below answers for the real state.
if (r.status === "accepted") await mutate("request.pick", { id: r.id });
const c = await mutate("request.collected", { id: r.id, sigId, slip });
if (!c.ok) { void reload(); return { ok: false, error: c.error }; }
basket.clear("picked", r.id);
void reload();
return {
ok: true,
done: {
head: "Request handed over",
sub: `${r.staffName} · ${plural(total, "item")}${slip ? " · slip sent" : ""}`,
shelfKeys: r.bag.map((l) => key(l.itemId, l.si)),
next: "work",
},
};
}}
/>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
/* Sign a ward round: every bag waiting for that ward (lib/today roundSheet, the same set the Rounds
segment counted), one signature for the lot through pickup.deliverWard, then Done. */
import { useMemo, useRef, useState } from "react";
import { useParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { plural, roundSheet } from "@/lib/today";
import { takePhoto, uploadPhoto } from "@/lib/photo";
import SignFlow from "@/components/SignFlow";
import { MBody, MButton, MEmpty, MField, MHead, MRule, MTop, inputStyle, useToast } from "@/components/m";
import { roundLines, segment } from "@/components/m/work/util";
const BATCH = 40; // pickup.deliverWard takes at most 40 bags a call
export default function RoundSign() {
const ward = segment(useParams<{ ward: string }>().ward);
const { s, mutate } = useSnap();
const { byId, staffById } = useDerived();
const toast = useToast();
// Held from the first render: delivering removes the ward from the sheet, and the Done screen
// must not turn into an empty round.
const [rows] = useState(() => roundSheet(s, byId, staffById).find((w) => w.ward === ward)?.rows ?? []);
const lines = useMemo(() => roundLines(rows, byId), [rows, byId]);
const [name, setName] = useState("");
const [proofId, setProofId] = useState<string | null>(null);
const [photoBusy, setPhotoBusy] = useState(false);
const delivered = useRef(new Set<string>());
if (!rows.length) {
return (
<>
<MTop title="Sign" back />
<MRule />
<MBody pad>
<MEmpty title="Nothing to deliver" />
<MButton label="Back to Work" href="/m/work?seg=rounds" />
</MBody>
</>
);
}
const items = lines.reduce((t, l) => t + l.qty, 0);
const people = new Set(rows.map((r) => r.p.staffId)).size;
const who = name.trim();
const takeProof = async () => {
if (photoBusy) return;
const data = await takePhoto();
if (!data) return;
setPhotoBusy(true);
const up = await uploadPhoto(mutate, "proof", data);
setPhotoBusy(false);
if ("error" in up) { toast(up.error); return; }
setProofId(up.id);
};
return (
<SignFlow
kind="round"
head={<MHead name={`${ward} round`} meta={`${plural(people, "person", "people")} · ${plural(items, "item")}`} />}
lines={lines.map((l) => ({ key: l.key, name: l.name, qty: l.qty }))}
signerName={who || `${ward}, nurse in charge`}
extra={
<>
<MField label="Received by">
<input value={name} onChange={(e) => setName(e.target.value)} autoComplete="off" maxLength={120} style={inputStyle} />
</MField>
<MButton small label={photoBusy ? "Saving photo…" : proofId ? "Photo attached · retake" : "Add handover photo"} onClick={() => void takeProof()} disabled={photoBusy} />
</>
}
barLabel="Delivered"
commit={async ({ sigId }) => {
// A ward with more bags than one call takes goes in batches; a retry skips what already went.
const ids = rows.map((r) => r.p.id).filter((x) => !delivered.current.has(x));
for (let i = 0; i < ids.length; i += BATCH) {
const part = ids.slice(i, i + BATCH);
const r = await mutate("pickup.deliverWard", { ids: part, deliveredTo: who, sigId, proofId });
if (!r.ok) return { ok: false, error: delivered.current.size ? `${delivered.current.size} of ${rows.length} bags recorded. ${r.error}` : r.error };
part.forEach((x) => delivered.current.add(x));
}
return {
ok: true,
done: { head: `${ward} delivered`, sub: `${plural(items, "item")} · signed by ${who || "the nurse in charge"}`, shelfKeys: [], next: "work" },
};
}}
/>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/* Delivery rounds are Work Rounds; each ward signs at /m/round/[ward]. */
export default function Rounds() {
redirect("/m/work?seg=rounds");
}
+171
View File
@@ -0,0 +1,171 @@
"use client";
/* The Scan tab: one camera for a staff badge, a garment or a shelf label, routed by lib/scanroute.
Under the Android shell MLKit's preview shows through the transparent viewfinder; in a browser a
<video> fills it through lib/webscan. */
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useSnap } from "@/lib/client";
import { isNative, startLive } from "@/lib/nativescan";
import { startWebLive } from "@/lib/webscan";
import { scanReject, scanTick } from "@/lib/feedback";
import { track } from "@/lib/analytics";
import { useKeepAwake } from "@/lib/wakelock";
import { SCAN_KIND_LABEL, resolveScan, type ScanHit } from "@/lib/scanroute";
import { AC3, GROUND, INK, MBody, MButton, MONO, MPill, MRule, MSection, MTabs, MTop, useScanFlash } from "@/components/m";
type Recent = { kind: string; label: string; code: string; href: string };
/* What this session resolved, newest first. Module memory: it survives moving between tabs and is
gone when the app is closed. */
let RECENT: Recent[] = [];
const OFF_GREY = "#b5b1af";
const EDGE = "#57534f";
const DEBOUNCE = 1500;
export default function ScanTab() {
const { s, isAdmin } = useSnap();
const router = useRouter();
const flash = useScanFlash();
const vid = useRef<HTMLVideoElement | null>(null);
const [armed, setArmed] = useState(true);
const [miss, setMiss] = useState<Extract<ScanHit, { kind: "unknown" | "inactive" }> | null>(null);
const [caption, setCaption] = useState<{ msg: string; denied: boolean } | null>(null);
const [recent, setRecent] = useState<Recent[]>([]);
const [typed, setTyped] = useState("");
const native = isNative();
const [nativeOk, setNativeOk] = useState(native);
const [nativeLive, setNativeLive] = useState(false);
useKeepAwake(true);
useEffect(() => { setRecent(RECENT); }, []);
const handle = useCallback((raw: string) => {
const hit = resolveScan(s, raw);
if (hit.kind === "unknown" || hit.kind === "inactive") {
scanReject();
if (hit.kind === "unknown") track("scan_miss", { kind: "unknown" });
setArmed(false);
setMiss(hit);
return;
}
setArmed(false);
const row: Recent = { kind: SCAN_KIND_LABEL[hit.kind], label: hit.label, code: raw.trim(), href: hit.href };
RECENT = [row, ...RECENT.filter((r) => r.href !== row.href)].slice(0, 4);
setRecent(RECENT);
flash(row.kind, row.label, () => router.push(hit.href));
}, [s, flash, router]);
const handleRef = useRef(handle); handleRef.current = handle;
// Native: MLKit live session behind the WebView while armed.
useEffect(() => {
if (!native || !nativeOk || !armed) return;
let session: { stop: () => Promise<void> } | null = null;
let cancelled = false;
let lastRaw = "", lastT = 0;
(async () => {
const r = await startLive((raw) => {
if (raw === lastRaw && Date.now() - lastT < DEBOUNCE) return;
lastRaw = raw; lastT = Date.now();
scanTick();
handleRef.current(raw);
});
if (cancelled) { await r.stop(); return; }
if (r.error === "native-unavailable") { setNativeOk(false); return; }
if (r.error) { setCaption({ msg: r.error, denied: true }); return; }
session = r;
setNativeLive(true);
})();
return () => { cancelled = true; setNativeLive(false); if (session) void session.stop(); };
}, [native, nativeOk, armed]);
// Browser: the shared BarcodeDetector loop into the viewfinder's video.
useEffect(() => {
if (nativeOk || !armed || !vid.current) return;
const session = startWebLive(vid.current, (raw) => handleRef.current(raw), DEBOUNCE, {
onStatus: (st) => setCaption(st.state === "ready" ? null : { msg: st.msg, denied: st.state === "denied" }),
});
return () => session.stop();
}, [nativeOk, armed]);
useEffect(() => {
track("scan_opened", { mode: "tab", engine: nativeOk ? "mlkit" : native ? "mlkit-missing" : "browser" });
}, [native, nativeOk]);
const again = () => { setMiss(null); setCaption(null); setArmed(true); };
const find = () => { const c = typed.trim(); if (!c) return; setTyped(""); handle(c); };
return (
<div className="tcx-scanui" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<MTop title="Scan" />
<MRule />
<MBody dark pad className="tcx-scanbody">
<div style={{ position: "relative", height: 300, margin: "-16px -16px 0", overflow: "hidden", background: nativeLive ? "transparent" : "radial-gradient(ellipse at 50% 45%, #3a3735 0, #151413 70%)" }} className="tcx-camwin">
{!nativeOk && <video ref={vid} autoPlay playsInline muted aria-hidden="true" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }} />}
<div aria-hidden="true" style={{ position: "absolute", left: 16, top: 14, display: "flex", gap: 8, alignItems: "center", color: "#fff", fontSize: 12, fontWeight: 800, letterSpacing: "0.1em" }}>
<i style={{ width: 10, height: 10, background: armed ? "var(--color-accent)" : EDGE }} />LIVE
</div>
<div aria-hidden="true" style={{ position: "absolute", left: "14%", right: "14%", top: "22%", bottom: "26%" }}>
{([["left", "top"], ["right", "top"], ["left", "bottom"], ["right", "bottom"]] as const).map(([x, y]) => (
<i key={x + y} style={{ position: "absolute", width: 34, height: 34, [x]: 0, [y]: 0, borderStyle: "solid", borderColor: "#fff", borderWidth: 0, [`border${y === "top" ? "Top" : "Bottom"}Width`]: 4, [`border${x === "left" ? "Left" : "Right"}Width`]: 4 }} />
))}
</div>
{armed && !caption?.denied && <div aria-hidden="true" className="tcx-vf-laser" style={{ position: "absolute", left: "8%", right: "8%", height: 2, background: "var(--color-accent)", boxShadow: "0 0 12px var(--color-accent)" }} />}
<div role={caption?.denied ? "alert" : undefined} style={{ position: "absolute", left: 12, right: 12, bottom: 16, textAlign: "center", color: caption ? AC3 : "#fff", fontWeight: 800, fontSize: 14, letterSpacing: caption ? 0 : "0.06em", textTransform: caption ? "none" : "uppercase" }}>
{caption ? caption.msg : "Badge, garment or shelf label"}
</div>
</div>
<div style={{ background: INK, margin: "0 -16px -16px", padding: "0 16px 16px", minHeight: "calc(100% - 268px)" }}>
<div style={{ height: 1 }} />
{miss ? (
<div style={{ paddingTop: 18 }}>
<MPill tone="accent">{miss.kind === "unknown" ? "Not found" : "Inactive"}</MPill>
<div style={{ fontFamily: miss.kind === "unknown" ? MONO : undefined, fontSize: 22, fontWeight: miss.kind === "unknown" ? 500 : 800, marginTop: 10, wordBreak: "break-all" }}>
{miss.kind === "unknown" ? miss.code || "—" : miss.label}
</div>
<div style={{ display: "grid", marginTop: 4 }}>
{miss.kind === "unknown" && (isAdmin
? <DarkButton label="Bind to a garment" href={`/m/catalogue?bind=${encodeURIComponent(miss.code)}`} />
: <div style={{ fontSize: 13, color: OFF_GREY, marginTop: 10 }}>Only an admin can bind a code</div>)}
{miss.kind === "inactive" && <DarkButton label="Open their record" href={`/m/person/${miss.staffId}`} />}
<DarkButton label="Scan again" onClick={again} />
</div>
</div>
) : recent.length > 0 && (
<>
<MSection label="Recent scans" right={recent.length} />
<div style={{ display: "grid", gap: 8, marginTop: 10 }}>
{recent.map((r) => (
<button key={r.href} type="button" onClick={() => router.push(r.href)}
style={{ display: "flex", alignItems: "center", gap: 12, minHeight: 56, border: "2px solid " + EDGE, background: "transparent", color: GROUND, padding: "0 12px", textAlign: "left", fontFamily: "inherit", fontSize: 15, fontWeight: 700, cursor: "pointer" }}>
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: OFF_GREY, width: 62, flex: "none" }}>{r.kind}</span>
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span>
<span style={{ fontFamily: MONO, fontSize: 12, color: OFF_GREY, fontWeight: 500 }}>{r.code}</span>
</button>
))}
</div>
</>
)}
<form onSubmit={(e) => { e.preventDefault(); find(); }} style={{ marginTop: 22 }}>
<label htmlFor="scan-typed" style={{ display: "block", fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: OFF_GREY, marginBottom: 6 }}>Type a code</label>
<div style={{ display: "flex", gap: 8 }}>
<input id="scan-typed" value={typed} onChange={(e) => setTyped(e.target.value)} autoComplete="off" autoCapitalize="none" autoCorrect="off" spellCheck={false}
style={{ flex: 1, minWidth: 0, height: 52, border: "2px solid " + EDGE, background: "transparent", color: GROUND, fontFamily: MONO, fontSize: 16, padding: "0 14px", borderRadius: 0 }} />
<button type="submit" style={{ minWidth: 72, minHeight: 52, border: "2px solid " + GROUND, background: GROUND, color: INK, fontFamily: "inherit", fontSize: 14, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer" }}>Find</button>
</div>
</form>
</div>
</MBody>
<MTabs active="scan" />
</div>
);
}
/** MButton's outline, drawn in ground on the ink body. */
function DarkButton({ label, href, onClick }: { label: string; href?: string; onClick?: () => void }) {
return (
<div style={{ "--color-text": "var(--color-bg)" } as React.CSSProperties}>
<MButton label={label} href={href} onClick={onClick} />
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { redirect } from "next/navigation";
/* Search became the People tab (stock search is on the Stock tab). The query comes along. */
export default async function Search({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
const q = await searchParams;
const term = typeof q.q === "string" ? q.q.trim().slice(0, 80) : "";
redirect(term ? `/m/people?q=${encodeURIComponent(term)}` : "/m/people");
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
/* Settings, behind the gear on Today: this phone, the account, help and signing out. Everything else
about the facility lives on the desktop, so there is one place a setting can be wrong. */
import { DELETE_ACCOUNT_URL, PRIVACY_URL, TERMS_URL } from "@/lib/links";
import { useEffect, useState } from "react";
import { useSnap } from "@/lib/client";
import { clearAllCounts } from "@/lib/opencount";
import { printState, type PrintState } from "@/lib/nativeprint";
import { clearBasket } from "@/components/MBasket";
import { MBody, MButton, MError, MONO, MPill, MRow, MRule, MSection, MStepper, MSwitchRow, MTop } from "@/components/m";
const BEEP_KEY = "tc.beep";
export default function MSettings() {
const { s, isAdmin, mutate, busy } = useSnap();
const [beep, setBeep] = useState(true);
const [gate, setGate] = useState(s.settings.varianceReason);
const [err, setErr] = useState("");
const [printer, setPrinter] = useState<PrintState | null>(null);
const [host, setHost] = useState("");
const [leaving, setLeaving] = useState(false);
useEffect(() => {
try { setBeep(localStorage.getItem(BEEP_KEY) !== "0"); } catch { /* blocked store */ }
setHost(window.location.host);
let live = true;
printState().then((p) => { if (live) setPrinter(p); }).catch(() => { /* stays blank */ });
return () => { live = false; };
}, []);
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 () => {
// Part-counted shelves and the half-built basket are this person's, on a phone that is passed
// around a linen room. Cleared before the logout POST so a failed request still leaves the
// device tidy. A full navigation afterwards: the session cookie is gone and every page behind
// it is server-rendered. /m/login, not /auth, keeps them inside the app.
setLeaving(true);
clearAllCounts(s.session.userId);
clearBasket(s.session.userId);
try { await fetch("/api/auth/logout", { method: "POST" }); } catch { /* the cookie check on /m/login decides */ }
window.location.replace("/m/login");
};
const roleWord = s.session.role === "Admin" ? "Admin" : "Issuer";
const tone = printer?.state === "ready" ? "ok" : printer?.state === "unavailable" ? "accent" : "mute";
return (
<>
<MTop title="Settings" back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
<MSection label="This phone" />
<MSwitchRow title="Beep and buzz on scan" on={beep} onToggle={toggleBeep} />
<MRow title="Shelf printer" sub={printer?.sub ?? ""} right={printer ? <MPill tone={tone}>{printer.label}</MPill> : null} />
<MRow title="Server" sub={host} />
<MSection label="Account" />
<MRow title={s.settings.facility} sub={s.session.title || roleWord} />
<MRow title="Count gap needing a reason"
right={isAdmin
? <MStepper n={gate} onChange={saveGate} min={1} max={99} label="gap" />
: <span style={{ fontFamily: MONO, fontWeight: 600, fontSize: 14 }}>{gate}</span>} />
<MRow title="Help" chev href="/app/help/apps/counter-app" />
{/* Deleting an account has to be reachable from inside the app (a Play requirement), and this
is the only place the signed-in counter app names the policies. They go to the site's own
pages: there is one account-deletion flow, and it is the one on the website. */}
{(DELETE_ACCOUNT_URL || PRIVACY_URL || TERMS_URL) && <MSection label="Your account and your data" />}
{DELETE_ACCOUNT_URL && <MRow href={DELETE_ACCOUNT_URL} external title="Delete your account" />}
{PRIVACY_URL && <MRow href={PRIVACY_URL} external title="Privacy policy" />}
{TERMS_URL && <MRow href={TERMS_URL} external title="Terms of use" />}
<div style={{ marginTop: 4 }}>
<MButton label="Sign out" onClick={signOut} disabled={busy || leaving} />
</div>
</MBody>
</>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
/* The old signed-in welcome. Today is the first screen now; a brand-new facility gets its banner. */
export default async function SignedIn({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
const q = await searchParams;
redirect(q.new === "1" ? "/m?flash=created" : "/m");
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
/* The Stock tab: on hand against par, worst first, in three segments, and the stock jobs underneath. */
import { useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { atReorderVariants } from "@/lib/portalcounts";
import { bcBound, flaggedNeeds, label, locMap, locTrail, onhand, reorderAt, touched } from "@/lib/compute";
import { MBody, MButton, MEmpty, MRow, MRule, MSearch, MSection, MSeg, MTabs, MTop, MTopCount } from "@/components/m";
import { PAGE, byShortfall, countRows, placedOnOrder } from "@/components/m/stock/stockdata";
type Seg = "below" | "order" | "all";
const SEGS: Seg[] = ["below", "order", "all"];
export default function MStock() {
const { s } = useSnap();
const { L, byId, variants } = useDerived();
const router = useRouter();
const sp = useSearchParams();
const [seg, setSeg] = useState<Seg>(() => {
const v = sp.get("seg") as Seg | null;
return v && SEGS.includes(v) ? v : "below";
});
const [q, setQ] = useState("");
const [shown, setShown] = useState(PAGE);
const data = useMemo(() => {
const locs = locMap(s);
const onOrd = placedOnOrder(s, byId);
const below = new Set(atReorderVariants(s, L).map((v) => v.key));
const all = variants.filter((v) => touched(s, L, v.key)).map((v) => ({
key: v.key, itemId: v.itemId, si: v.si, size: v.size,
name: label(v.item), oh: onhand(s, L, v.key), par: reorderAt(s, v.key),
code: bcBound(s, v.item, v.si), shelf: locTrail(locs, s.placed[v.key], 0),
below: below.has(v.key), onOrder: (onOrd[v.key] || 0) > 0,
}));
const order = variants.filter((v) => (onOrd[v.key] || 0) > 0).length;
return { all, below: below.size, order, drafts: flaggedNeeds(s, L, byId).length };
}, [s, L, byId, variants]);
const shelves = useMemo(() => countRows(s, L, variants).length, [s, L, variants]);
const rows = useMemo(() => {
const needle = q.trim().toLowerCase();
return data.all
.filter((r) => seg === "all" || (seg === "below" ? r.below : r.onOrder))
.filter((r) => !needle || `${r.name} ${r.size} ${r.code} ${r.shelf}`.toLowerCase().includes(needle))
.sort(byShortfall);
}, [data, seg, q]);
const pick = (k: Seg) => {
setSeg(k); setShown(PAGE);
router.replace(`/m/stock?seg=${k}`, { scroll: false });
};
return (
<>
<MTop title="Stock" right={<MTopCount>{data.below} short</MTopCount>} />
<MRule />
<MBody pad>
<MSearch value={q} onChange={(v) => { setQ(v); setShown(PAGE); }} placeholder="Garment or size" label="Filter stock" scanHref="/m/scan" scanLabel="Scan a garment" />
<div style={{ marginTop: 10 }}>
<MSeg label="Stock lines" value={seg} onPick={pick} options={[
{ key: "below", label: "Below par", n: data.below },
{ key: "order", label: "On order", n: data.order },
{ key: "all", label: "All", n: data.all.length },
]} />
</div>
{rows.length === 0
? <MEmpty title="Nothing here" sub="Try All, or clear the search." />
: rows.slice(0, shown).map((r) => (
<MRow key={r.key} href={`/m/line/${encodeURIComponent(r.itemId)}/${r.si}`}
mark={r.oh <= 0 ? "accent" : r.oh <= r.par ? "ink" : "mute"}
title={`${r.name} ${r.size}`} right={`${r.oh}/${r.par}`} />
))}
{rows.length > shown && (
<>
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 10 }}>Showing {shown} of {rows.length}</div>
<MButton small label="Show more" onClick={() => setShown((n) => n + PAGE)} />
</>
)}
<MSection label="Also in Stock" />
<MRow mark="mute" chev href="/m/reorder" title={`Draft order · ${data.drafts} line${data.drafts === 1 ? "" : "s"}`} />
<MRow mark="mute" chev href="/m/variance" title="Variance over time" />
<MRow mark="mute" chev href="/m/catalogue" title="Catalogue" />
<MRow mark="mute" chev href="/m/count" title={`Count a shelf · ${shelves} shel${shelves === 1 ? "f" : "ves"}`} />
</MBody>
<MTabs active="stock" />
</>
);
}
+101
View File
@@ -0,0 +1,101 @@
"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 figure. */
import { useMemo, useState } from "react";
import { useSnap } from "@/lib/client";
import { itemMap, label, monthLabel } from "@/lib/compute";
import { INK, MBody, MButton, MEmpty, MKick, MONO, MRule, MSection, MTop, MTopCount } from "@/components/m";
const MAX_BAR = 56;
const PAGE = 40;
export default function MVarianceOverTime() {
const { s } = useSnap();
const [shown, setShown] = useState(PAGE);
const { rows, counts } = useMemo(() => {
const byId = itemMap(s);
// Oldest first, shelf counts only, the 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: `${label(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";
return { key: k, name: v.name, gaps: v.gaps, latest, verdict, persistent: shortEvery || worsening };
});
// Worst pattern first: persistent problems, then the 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))));
const month = (d: string, m: "short" | "long") => monthLabel(d.slice(0, 7), { month: m });
return (
<>
<MTop title="Variance over time" back right={<MTopCount>{counts.length} count{counts.length === 1 ? "" : "s"}</MTopCount>} />
<MRule />
<MBody pad>
{counts.length > 0 && <MKick>Gap at each count since {month(counts[0].date, "long")}</MKick>}
{rows.length === 0 ? (
<MEmpty title="No counts to compare yet" sub="Commit two shelf counts to see a pattern." />
) : (
<>
<MSection label="Lines" right={rows.length} />
{rows.slice(0, shown).map((r) => (
<div key={r.key} style={{ padding: "12px 0", borderBottom: "1px solid var(--color-divider)", boxShadow: r.persistent ? "inset 4px 0 0 var(--color-accent)" : undefined, paddingLeft: r.persistent ? 12 : 0 }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 700 }}>{r.name}</div>
<div style={{ fontSize: 13, color: r.persistent ? "var(--color-accent-700)" : "var(--color-neutral-600)", fontWeight: r.persistent ? 800 : 400, marginTop: 1 }}>{r.verdict}</div>
</div>
<div style={{ fontFamily: MONO, fontWeight: 600, fontSize: 14, color: r.latest === 0 ? INK : "var(--color-accent-700)", whiteSpace: "nowrap" }}>
{r.latest === 0 ? "Match" : r.latest > 0 ? `+${r.latest}` : `${-r.latest}`}
</div>
</div>
<div className="tcx-chart" style={{ marginTop: 12 }} role="img"
aria-label={`Gap at each count: ${r.gaps.map((g, i) => `${counts[i] ? month(counts[i].date, "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 aria-hidden="true" style={{ display: "flex", gap: 5, marginTop: 6 }}>
{counts.map((t, i) => (
<span key={i} style={{ flex: 1, textAlign: "center", fontSize: 12, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>
{month(t.date, "short")}
</span>
))}
</div>
</div>
))}
{rows.length > shown && (
<>
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 10 }}>Showing {shown} of {rows.length}</div>
<MButton small label="Show more" onClick={() => setShown((n) => n + PAGE)} />
</>
)}
</>
)}
</MBody>
</>
);
}
+184
View File
@@ -0,0 +1,184 @@
"use client";
/* The Work tab: the queue at the window. Approved requests to pick, deliveries to receive, pickups to
call and ward rounds, counted by the same useWorkCount() as the tab badge and Today. */
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { collectRows, plural, receiveRows, relativeDay, roundSheet } from "@/lib/today";
import { PICK_STATUSES, useWorkCount } from "@/lib/workcount";
import { useRequests } from "@/components/requests/RequestList";
import {
MBody, MButton, MCard, MCardChip, MEmpty, MError, MKick, MPill, MRow, MRule, MSection, MSeg, MTabs, MTop, MTopCount, useToast,
} from "@/components/m";
import { openReceivable, orderWhen, outstandingTotal } from "@/components/m/work/util";
type Seg = "picks" | "in" | "pickups" | "rounds";
const SEGS: readonly Seg[] = ["picks", "in", "pickups", "rounds"];
const asSeg = (v: string | null): Seg => (SEGS.includes(v as Seg) ? (v as Seg) : "picks");
export default function Work() {
const sp = useSearchParams();
const [seg, setSeg] = useState<Seg>(() => asSeg(sp.get("seg")));
const w = useWorkCount();
const [err, setErr] = useState("");
// A link from Today (?seg=in) lands on its segment even when Work is already mounted.
useEffect(() => { setSeg(asSeg(sp.get("seg"))); }, [sp]);
const pick = (k: Seg) => {
setSeg(k);
setErr("");
// Shallow: the segment is only a view of the snapshot already on the phone. null state, so Next's
// router takes the new URL as canonical and a later refresh doesn't put the old segment back.
try { window.history.replaceState(null, "", `/m/work?seg=${k}`); } catch { /* not fatal */ }
};
return (
<>
<MTop title="Work" right={<MTopCount>{w.total} open</MTopCount>} />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
<MSeg label="Work" value={seg} onPick={pick} options={[
{ key: "picks", label: "Picks", n: w.picks },
{ key: "in", label: "In", n: w.in },
{ key: "pickups", label: "Pickups", n: w.pickups },
{ key: "rounds", label: "Rounds", n: w.rounds },
]} />
{seg === "picks" && <Picks />}
{seg === "in" && <Inbound />}
{seg === "pickups" && <Pickups onError={setErr} />}
{seg === "rounds" && <Rounds />}
</MBody>
<MTabs active="work" workBadge={w.total} />
</>
);
}
const line = (text: string) => <span style={{ display: "block" }}>{text}</span>;
function Picks() {
const { s } = useSnap();
const { staffById } = useDerived();
const { data, error, reload } = useRequests();
const rows = useMemo(() => (data?.requests ?? [])
.filter((r) => PICK_STATUSES.has(r.status))
.sort((a, b) => (a.decidedAt || a.createdAt).localeCompare(b.decidedAt || b.createdAt)), [data]);
if (!data) {
if (error) return (
<div style={{ marginTop: 14 }}>
<MError msg="Requests couldnt be loaded." />
<MButton small label="Try again" onClick={() => void reload()} />
</div>
);
return <div style={{ marginTop: 14 }}><MKick>Loading</MKick></div>;
}
if (!rows.length) return <MEmpty title="Nothing to pick" sub="Approved requests land here." />;
return (
<>
{rows.map((r) => {
const st = staffById[r.staffId];
const what = r.bag.map((l) => `${l.item.toLowerCase()} ${l.size}`).join(", ");
const when = relativeDay(r.decidedAt, s);
const approved = [when ? `Approved ${when}` : "Approved", r.managerName].filter(Boolean).join(" · ")
+ (r.status === "ready" && r.collectCode ? ` · ready, code ${r.collectCode}` : "");
return (
<MRow key={r.id} mark="ink" chev href={`/m/request/${r.id}`} title={r.staffName}
sub={<>{line([st?.group || r.ward, what].filter(Boolean).join(" · "))}{line(approved)}</>}
right={r.garments} />
);
})}
</>
);
}
function Inbound() {
const { s } = useSnap();
const { byId, staffById } = useDerived();
const soon = useMemo(() => receiveRows(s, staffById), [s, staffById]);
const later = useMemo(() => {
const ids = new Set(soon.map((r) => r.o.id));
return openReceivable(s, byId).filter((o) => !ids.has(o.id));
}, [s, byId, soon]);
const row = (o: (typeof later)[number], forName: string) => (
<MRow key={o.id} mark="ink" chev href={`/m/receive/${o.id}`} title={`${o.code} · ${o.supplier || "Supplier"}`}
sub={[orderWhen(s, o), plural(o.lines.length, "line"), forName ? `for ${forName}` : ""].filter(Boolean).join(" · ")}
right={outstandingTotal(o, byId)} />
);
if (!soon.length && !later.length) return <MEmpty title="Nothing to receive" sub="Open orders show here when they arrive." />;
return (
<>
{soon.map((r) => row(r.o, r.forName))}
{later.length > 0 && (
<>
<MSection label="Later" right={later.length} />
{later.map((o) => row(o, o.staffId ? (staffById[o.staffId] ? `${staffById[o.staffId].first} ${staffById[o.staffId].last}`.trim() : "") : ""))}
</>
)}
</>
);
}
function Pickups({ onError }: { onError: (msg: string) => void }) {
const { s, mutate } = useSnap();
const { byId, staffById } = useDerived();
const toast = useToast();
const rows = useMemo(() => collectRows(s, byId, staffById, { includeRound: true }), [s, byId, staffById]);
const [busy, setBusy] = useState<string | null>(null);
const run = async (id: string, op: string, payload: Record<string, unknown>, then?: () => void) => {
if (busy) return;
setBusy(id);
onError("");
const r = await mutate(op, payload);
setBusy(null);
if (!r.ok) { onError(r.error); return; }
then?.();
};
if (!rows.length) return <MEmpty title="No one waiting" sub="Orders in for a person show here when they arrive." />;
return (
<>
{rows.map((r) => {
const items = r.lines.map((l) => `${l.garment} ${l.size} ×${l.qty}`).join(", ");
const id = r.p.id;
return (
<MCard key={id} title={r.name} sub={[r.st?.dept, items].filter(Boolean).join(" · ")}
pill={<MPill tone={r.late ? "accent" : "mute"}>{r.days}d</MPill>}
actions={
<>
<MCardChip label="Call" href={r.tel || undefined} disabled={!r.tel} />
<MCardChip label={r.p.contacted ? "Contacted" : "Contacted?"} on={r.p.contacted} disabled={busy === id}
onClick={() => void run(id, "pickup.contacted", { id, contacted: !r.p.contacted })} />
<MCardChip label="Collected" disabled={busy === id}
onClick={() => void run(id, "pickup.pickedUp", { id }, () => toast(`${r.name} collected ${items}`))} />
</>
} />
);
})}
</>
);
}
function Rounds() {
const { s } = useSnap();
const { byId, staffById } = useDerived();
const wards = useMemo(() => roundSheet(s, byId, staffById), [s, byId, staffById]);
if (!wards.length) return <MEmpty title="No rounds today" sub="Delivered rounds are in History on the desktop." />;
return (
<>
{wards.map((w) => {
const people = new Set(w.rows.map((r) => r.p.staffId)).size;
return (
<MRow key={w.ward} mark="ink" chev href={`/m/round/${encodeURIComponent(w.ward)}`} title={w.ward}
sub={`${plural(people, "person", "people")} · ${plural(w.rows.length, "bag")}`} right={w.garments} />
);
})}
</>
);
}
+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";
})();
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} />;
}

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