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