ThreadCount Community edition
Release / release (push) Has been skipped

Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 794bab5 on 2026-09-16. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-17 05:49:16 +10:00
commit f72f0626b0
481 changed files with 59411 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
.next
.photos
.git
.claude
docs
# The manual is read by the app at request time (Help), so it has to be in the image.
!docs/manual
patches/*.orig
.env
.env.*
!.env.example
+52
View File
@@ -0,0 +1,52 @@
# Every variable the Community edition reads. docker-compose.yml passes this file to the app and
# fills DATABASE_URL, PORT and PHOTO_DIR itself.
#
# The variables beginning NEXT_PUBLIC_ are compiled into the browser bundle at build time, not read
# at runtime — set them before the first `docker compose up --build`, and rebuild if they change.
# ---- required ----
# Signs every session cookie. Generate one per instance (`openssl rand -base64 48`). The server
# refuses to start while it still says change-me.
SESSION_SECRET=change-me
# The bundled database's password (docker-compose.yml only).
POSTGRES_PASSWORD=
# The address people open the app at, e.g. https://uniforms.example.health. Used in emailed links.
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# Always "community" on your own server.
EDITION=community
# ---- your documents ----
# The staff sign-in and the account screens link to a terms page and a privacy notice. Point them
# at your own; until you do they point at threadcount.tech's, which describe the hosted service.
NEXT_PUBLIC_TERMS_URL=
NEXT_PUBLIC_PRIVACY_URL=
# ---- transactional mail (optional) ----
# With these unset nothing is sent: password resets and approval links are handled at the counter,
# and the screens say so rather than claiming otherwise.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM="ThreadCount <no-reply@example.health>"
# ---- optional ----
# 1 hides the create-account form and refuses the sign-up endpoint. Set it once your facility exists.
SIGNUPS_DISABLED=
# Cloudflare Turnstile on sign-in and sign-up. Set both to enforce it; leave both blank to rely on
# the per-address rate limits alone.
TURNSTILE_SECRET=
NEXT_PUBLIC_TURNSTILE_SITEKEY=
# Your own GlitchTip (Sentry-protocol) DSN for error reports. Blank = nothing is reported anywhere.
NEXT_PUBLIC_GLITCHTIP_DSN=
# Stamped on error reports so a fault can be tied to a build.
NEXT_PUBLIC_RELEASE=
# Host port docker-compose.yml publishes the app on (the container always listens on 3000).
APP_PORT=3000
# Your own Umami, if any: the tracker script address and the site ids. Blank = no statistics sent.
NEXT_PUBLIC_UMAMI_SRC=
NEXT_PUBLIC_UMAMI_SITE_ID=
NEXT_PUBLIC_UMAMI_APP_ID=
# Your own Chatwoot, if any: a chat widget on the coordinator app. Blank = no widget.
NEXT_PUBLIC_CHATWOOT_URL=
NEXT_PUBLIC_CHATWOOT_TOKEN=
+34
View File
@@ -0,0 +1,34 @@
# Turns a pushed release tag into a GitHub Release, using the notes shipped with that tag.
# Tags are named v<year>.<month>.<day>, with .2 or .3 added for a second release on the same day.
name: Release
on:
push:
tags: ["v*"]
permissions:
contents: write
jobs:
release:
# Only GitHub creates Releases; a mirror elsewhere that runs Actions skips this job.
if: github.server_url == 'https://github.com'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
echo "release $TAG already exists"
exit 0
fi
notes=RELEASE_NOTES.md
if [ ! -s "$notes" ]; then
notes="$RUNNER_TEMP/notes.md"
printf 'ThreadCount Community %s\n' "$TAG" > "$notes"
fi
gh release create "$TAG" --verify-tag --title "ThreadCount Community $TAG" --notes-file "$notes"
+86
View File
@@ -0,0 +1,86 @@
# 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/
# Firebase config for the staff app: kept outside the checkout, copied in before a build
android-staff/app/google-services.json
+49
View File
@@ -0,0 +1,49 @@
# Changelog
What changed in each Community release, newest first. Each heading is the date a release was cut. Its tag is `v` followed by that date, such as `v2026.09.17`, with `.2` added if a second release went out the same day.
## 2026-09-17
The first tagged release. Earlier Community builds were published without tags.
### Licence: now AGPL-3.0-only
- ThreadCount Community is now under the **GNU Affero General Public License v3**, an OSI-approved
open source licence. It was previously the Functional Source Licence, which was source-available
but not open source. Run it, read it, change it, fork it; the one obligation is the copyleft one,
that a modified version you run for other people must let those people have your changes.
- Releases published before this one remain under the earlier licence.
- The sign-in page and the app now carry the licence's offer of source: the build you are running
and a link to its code. Set `SOURCE_URL` if you run a modified build, so it points at yours.
- `CONTRIBUTING.md` asks contributors for a Developer Certificate of Origin sign-off. It is a
sign-off, not a copyright assignment — you keep the copyright in your work.
- The ThreadCount name and logo are not covered by the licence; a fork needs its own name.
- The README now opens with the live demo and shows what the product looks like.
### Phone counter (`/m`)
- Rebuilt around one scanner. A staff badge opens that person, a garment opens its stock line, and a shelf label starts a count of that shelf.
- Issuing is a basket of the person's kit in their size, with tops and pants counted against the sets limit as you add. A line that breaks a rule needs a reason.
- A hand-over can be signed on the glass, and the slip can be sent to the person's staff app.
- Hand back takes several garments at once, each with a condition and an optional size swap.
- A Work tab collects approved requests to pick, deliveries to receive, pickups and ward rounds.
- The Android app prints labels through Android's print service. Shelf labels can be printed and scanned to start a count.
- Counting has a hands-free switch, and every gap over the facility threshold needs a reason before the count is committed.
### Staff app (`/my`)
- Notifications for approved or declined requests, ready to collect, bags on the ward round, kit checks, and for approvers anything waiting on them. They need a Firebase project on your server: see Self-hosting, Notifications in the manual. Without one the app says notifications are not set up.
- A Messages tab listing every thread with the linen room.
- A Team tab for approvers and ward desks: approve from the queue, the ward roster, raising for your team, and the ward round with progress on "Sign for all".
- Asking for a garment is one screen, with what you already hold first and "Same again" to repeat your last request.
- Signed slips show quantities.
- An offline bar keeps what you typed while the signal is gone.
### Coordinator app (`/app`)
- Rebuilt around seven sections: Today, Counter, Stock, Orders, People, Reports and Settings, with a search-and-scan bar.
### Sign-in and sign-up
- A terms and privacy tick box on sign-in and on the first sign-up step, shown when your server sets `NEXT_PUBLIC_TERMS_URL` or `NEXT_PUBLIC_PRIVACY_URL`.
- Where Turnstile is configured, its check is visible on the password step and on the last sign-up step.
### Updating
- This release adds database migrations. They run by themselves during `docker compose up -d --build`. Take a backup first, as the updating guide describes.
+1
View File
@@ -0,0 +1 @@
community v2026.09.17.2 794bab5
+43
View File
@@ -0,0 +1,43 @@
# Contributing
Bug reports and patches are welcome. The product is written for a real linen room, so the most
useful thing you can send is what went wrong in yours: what you were doing, what you expected, and
what happened instead.
## Reporting
- **A bug or a question:** open an issue.
- **A security problem:** email security@threadcount.tech instead. Please don't open a public issue
for it.
## Sending a patch
1. Open an issue first for anything beyond a small fix, so the approach can be agreed before you
spend an evening on it.
2. Keep the change focused, and match the surrounding code — comments that explain *why*, plain
words on screen, no dependency added without a reason.
3. Say how you tested it. There is no CI on this repository; a description of what you ran is what
a reviewer has to go on.
## Sign your commits (DCO)
Every commit needs a `Signed-off-by` line, which `git commit -s` adds for you:
```
Signed-off-by: Your Name <your.email@example.com>
```
That line means you agree to the [Developer Certificate of Origin 1.1](https://developercertificate.org/):
that you wrote the patch, or have the right to submit it under this project's licence, and that
you understand it will be public and kept in the record.
It is a sign-off, not a copyright assignment — you keep the copyright in your work, and it is
contributed under the project's licence (AGPL-3.0-only).
## Licence
Contributions are accepted under the [AGPL-3.0-only](LICENSE) licence that covers the Community
edition. The ThreadCount name and logo are not covered by it.
A commercial licence of the same code is offered to organisations that cannot use the AGPL. If your
patch is merged, it stays under the AGPL here; it is only ever relicensed with your agreement.
+50
View File
@@ -0,0 +1,50 @@
# ThreadCount, Community edition — one image, built where it runs.
#
# Built locally by `docker compose build` rather than pulled, and for a reason: everything that
# starts NEXT_PUBLIC_ is compiled into the browser bundle, so the address people will open the app
# at (NEXT_PUBLIC_SITE_URL) and the optional Turnstile site key have to be known at build time.
# docker-compose.yml passes them in from your .env as build arguments.
#
# Three stages:
# deps — node_modules from the lockfile (patch-package runs in postinstall).
# builder — the Prisma client and the Next build. Also the image `docker compose run migrate`
# uses, because it still has the Prisma CLI.
# runner — Next's standalone output only: no compiler, no CLI, a non-root user.
FROM node:24-bookworm-slim AS deps
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
COPY patches ./patches
RUN npm ci --ignore-scripts && npx patch-package
FROM deps AS builder
WORKDIR /app
COPY . .
ARG NEXT_PUBLIC_SITE_URL=http://localhost:3000
ARG NEXT_PUBLIC_TURNSTILE_SITEKEY=
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL \
NEXT_PUBLIC_TURNSTILE_SITEKEY=$NEXT_PUBLIC_TURNSTILE_SITEKEY \
EDITION=community \
NEXT_OUTPUT=standalone \
NEXT_TELEMETRY_DISABLED=1
RUN npx prisma generate && npm run build
FROM node:24-bookworm-slim AS runner
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates curl && rm -rf /var/lib/apt/lists/* \
&& groupadd -r threadcount && useradd -r -g threadcount -d /app threadcount \
&& mkdir -p /data/photos && chown -R threadcount:threadcount /data
ENV NODE_ENV=production \
EDITION=community \
PHOTO_DIR=/data/photos \
PORT=3000 \
HOSTNAME=0.0.0.0 \
NEXT_TELEMETRY_DISABLED=1
COPY --from=builder --chown=threadcount:threadcount /app/.next/standalone ./
COPY --from=builder --chown=threadcount:threadcount /app/.next/static ./.next/static
COPY --from=builder --chown=threadcount:threadcount /app/public ./public
USER threadcount
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl -fsS http://127.0.0.1:3000/api/health || exit 1
CMD ["node", "server.js"]
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+191
View File
@@ -0,0 +1,191 @@
<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).
**[Open the live demo](https://threadcount.tech/demo)** — a shared facility with invented data, no
sign-up, rebuilt every twenty minutes. Issue a garment, run a count, look at the reports.
## 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.
## What it looks like
The demo facility, on a laptop and on a phone. Nothing here is a real room, a real person or a real
supplier — the demo's data is invented and rebuilt every twenty minutes.
| The coordinator app | The counter |
|---|---|
| [![The dashboard: what is low, what is on order, what was issued today](docs/screenshots/coordinator-dashboard.png)](docs/screenshots/coordinator-dashboard.png) | [![Issuing at the counter against a person's entitlement](docs/screenshots/counter-desktop.png)](docs/screenshots/counter-desktop.png) |
| **Stock** | **Reports** |
| [![Stock by item and size, with reorder levels and what is on order](docs/screenshots/stock.png)](docs/screenshots/stock.png) | [![Reports: spend by ward, issues, variance and the month-end pack](docs/screenshots/reports.png)](docs/screenshots/reports.png) |
<p align="center">
<img src="docs/screenshots/phone-counter.png" alt="The phone counter: scan a badge or a garment to issue, count a shelf or pick a round" width="300">
<br><em>The phone counter — the camera reads badges, garments and shelf labels.</em>
</p>
## 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.
`SOURCE_URL` matters if you change the code. The sign-in page and the app show a "Source code"
link, which by default names the release your build came from. **If you modify ThreadCount and run
it for other people, the AGPL asks that those people can get your modified source** — set
`SOURCE_URL` to wherever you keep it and the link follows.
## Licence
GNU Affero General Public License, version 3 (AGPL-3.0-only) — an OSI-approved open source
licence. Run it, read it, change it, and share it. The one obligation that matters: if you modify
it and let other people use your modified version over a network, those users must be able to get
your changed source. Running the published version unmodified asks nothing of you. Full text in
[LICENSE](LICENSE).
This covers the Community edition published at
[github.com/pricehq/threadcount-community](https://github.com/pricehq/threadcount-community).
The hosted service's own code — the website, billing, single sign-on and the tooling behind it —
is not published and is not covered by this licence. The **ThreadCount name and logo** are not
licensed either; a fork needs its own name.
If the AGPL does not suit your organisation, a commercial licence for the same code is available:
kyle@threadcount.tech.
## 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).
+42
View File
@@ -0,0 +1,42 @@
The first tagged release. Earlier Community builds were published without tags.
### Licence: now AGPL-3.0-only
- ThreadCount Community is now under the **GNU Affero General Public License v3**, an OSI-approved
open source licence. It was previously the Functional Source Licence, which was source-available
but not open source. Run it, read it, change it, fork it; the one obligation is the copyleft one,
that a modified version you run for other people must let those people have your changes.
- Releases published before this one remain under the earlier licence.
- The sign-in page and the app now carry the licence's offer of source: the build you are running
and a link to its code. Set `SOURCE_URL` if you run a modified build, so it points at yours.
- `CONTRIBUTING.md` asks contributors for a Developer Certificate of Origin sign-off. It is a
sign-off, not a copyright assignment — you keep the copyright in your work.
- The ThreadCount name and logo are not covered by the licence; a fork needs its own name.
- The README now opens with the live demo and shows what the product looks like.
### Phone counter (`/m`)
- Rebuilt around one scanner. A staff badge opens that person, a garment opens its stock line, and a shelf label starts a count of that shelf.
- Issuing is a basket of the person's kit in their size, with tops and pants counted against the sets limit as you add. A line that breaks a rule needs a reason.
- A hand-over can be signed on the glass, and the slip can be sent to the person's staff app.
- Hand back takes several garments at once, each with a condition and an optional size swap.
- A Work tab collects approved requests to pick, deliveries to receive, pickups and ward rounds.
- The Android app prints labels through Android's print service. Shelf labels can be printed and scanned to start a count.
- Counting has a hands-free switch, and every gap over the facility threshold needs a reason before the count is committed.
### Staff app (`/my`)
- Notifications for approved or declined requests, ready to collect, bags on the ward round, kit checks, and for approvers anything waiting on them. They need a Firebase project on your server: see Self-hosting, Notifications in the manual. Without one the app says notifications are not set up.
- A Messages tab listing every thread with the linen room.
- A Team tab for approvers and ward desks: approve from the queue, the ward roster, raising for your team, and the ward round with progress on "Sign for all".
- Asking for a garment is one screen, with what you already hold first and "Same again" to repeat your last request.
- Signed slips show quantities.
- An offline bar keeps what you typed while the signal is gone.
### Coordinator app (`/app`)
- Rebuilt around seven sections: Today, Counter, Stock, Orders, People, Reports and Settings, with a search-and-scan bar.
### Sign-in and sign-up
- A terms and privacy tick box on sign-in and on the first sign-up step, shown when your server sets `NEXT_PUBLIC_TERMS_URL` or `NEXT_PUBLIC_PRIVACY_URL`.
- Where Turnstile is configured, its check is visible on the password step and on the last sign-up step.
### Updating
- This release adds database migrations. They run by themselves during `docker compose up -d --build`. Take a backup first, as the updating guide describes.
+121
View File
@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import QRCode from "qrcode";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/session";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import {
decryptSecret, encryptSecret, hashRecoveryCode, newRecoveryCodes, newTotpSecret, otpauthUrl, totpVerify,
} from "@/lib/totp";
import { recordAuthEvent } from "@/lib/audit";
export const dynamic = "force-dynamic";
/* Turning a second factor on and off, for your own account only.
*
* Three steps rather than one, because a secret that is stored the moment it is generated leaves
* an account half-enrolled if the person never finishes — and then their next sign-in asks for
* codes from an app they never set up.
*
* setup — generate a secret and show the QR. Stored, but not yet in force.
* enable — prove a code from it works, then switch it on and hand back recovery codes.
* disable — password required, because turning a factor off is a privileged act.
*/
export async function GET() {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const u = await prisma.user.findUnique({ where: { id: user.id }, select: { totpEnabledAt: true } });
const left = await prisma.recoveryCode.count({ where: { userId: user.id, usedAt: null } });
return NextResponse.json({ enabled: !!u?.totpEnabledAt, enabledAt: u?.totpEnabledAt ?? null, recoveryLeft: left });
}
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const ip = clientIp(req.headers);
if (!allow("2fa-manage:" + user.id, 30, 15 * 60 * 1000)) {
return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 });
}
// Turning a second factor on or off is one of the few changes to an account that leaves no trace
// in the records themselves, so it is one of the few worth recording on its own.
const actor = {
facilityId: user.facilityId, userId: user.id,
userName: `${user.first} ${user.last}`.trim() || user.email,
};
let body: { action?: unknown; code?: unknown; password?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const action = String(body.action ?? "");
const u = await prisma.user.findUnique({
where: { id: user.id },
select: { id: true, email: true, passwordHash: true, totpSecret: true, totpEnabledAt: true },
});
if (!u) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
if (action === "setup") {
if (u.totpEnabledAt) return NextResponse.json({ error: "Two-factor is already on for this account." }, { status: 400 });
const secret = newTotpSecret();
await prisma.user.update({ where: { id: u.id }, data: { totpSecret: encryptSecret(secret) } });
const url = otpauthUrl(secret, u.email);
// SVG, generated here rather than in the browser: it keeps a QR library out of the bundle that
// ward phones download, and the secret never has to be handed to client-side code to render.
const qr = await QRCode.toString(url, { type: "svg", margin: 1, width: 220, errorCorrectionLevel: "M" });
recordAuthEvent(actor, "2fa:setup", ip);
return NextResponse.json({ ok: true, secret, url, qr });
}
if (action === "enable") {
if (u.totpEnabledAt) return NextResponse.json({ error: "Two-factor is already on." }, { status: 400 });
const secret = decryptSecret(u.totpSecret);
if (!secret) return NextResponse.json({ error: "Start the setup again." }, { status: 400 });
if (!totpVerify(secret, String(body.code ?? ""))) {
return NextResponse.json({ error: "That code isn't right. Use the current one from your app." }, { status: 400 });
}
const codes = newRecoveryCodes();
await prisma.$transaction(async (tx) => {
await tx.user.update({ where: { id: u.id }, data: { totpEnabledAt: new Date() } });
await tx.recoveryCode.deleteMany({ where: { userId: u.id } });
await tx.recoveryCode.createMany({ data: codes.map((c) => ({ userId: u.id, codeHash: hashRecoveryCode(c) })) });
});
recordAuthEvent(actor, "2fa:enable", ip);
// The only time these are ever readable. They are stored hashed, so there is no second chance.
return NextResponse.json({ ok: true, codes });
}
if (action === "disable") {
if (!u.totpEnabledAt) return NextResponse.json({ ok: true });
const pw = String(body.password ?? "");
if (!pw || !(await bcrypt.compare(pw, u.passwordHash))) {
return NextResponse.json({ error: "That password isn't right." }, { status: 401 });
}
await prisma.$transaction(async (tx) => {
await tx.user.update({ where: { id: u.id }, data: { totpEnabledAt: null, totpSecret: "" } });
await tx.recoveryCode.deleteMany({ where: { userId: u.id } });
});
recordAuthEvent(actor, "2fa:disable", ip);
return NextResponse.json({ ok: true });
}
if (action === "regenerate") {
if (!u.totpEnabledAt) return NextResponse.json({ error: "Two-factor isn't on." }, { status: 400 });
const pw = String(body.password ?? "");
if (!pw || !(await bcrypt.compare(pw, u.passwordHash))) {
return NextResponse.json({ error: "That password isn't right." }, { status: 401 });
}
const codes = newRecoveryCodes();
await prisma.$transaction(async (tx) => {
await tx.recoveryCode.deleteMany({ where: { userId: u.id } });
await tx.recoveryCode.createMany({ data: codes.map((c) => ({ userId: u.id, codeHash: hashRecoveryCode(c) })) });
});
recordAuthEvent(actor, "2fa:regenerate", ip);
return NextResponse.json({ ok: true, codes });
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
}
+58
View File
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/session";
export const dynamic = "force-dynamic";
const PAGE = 100;
/* The audit trail, read back.
*
* Admin only, and scoped to the caller's own facility by the query rather than by a filter the
* client sends — the client never gets to say which facility it wants. Paged by cursor rather
* than offset so a busy room's log doesn't shift under you as new rows land while you read.
*
* The cursor is (timestamp, id), not timestamp alone. Prisma stores DateTime at millisecond
* precision, and two events sharing a millisecond is ordinary rather than exotic — two coordinators
* saving at once, or two ops committed inside one transaction. A strict `at < cursor` dropped every
* row that shared the last one's millisecond, so the log looked complete with an event missing from
* it, which is the one failure an audit trail cannot have.
*/
/** `<iso>|<id>` — one opaque string, because the client only ever hands it straight back. */
function readCursor(raw: string | null): { at: Date; id: string } | null {
if (!raw) return null;
const cut = raw.lastIndexOf("|");
const iso = cut === -1 ? raw : raw.slice(0, cut);
const id = cut === -1 ? "" : raw.slice(cut + 1);
if (Number.isNaN(Date.parse(iso))) return null;
return { at: new Date(iso), id: id.slice(0, 40) };
}
export async function GET(req: NextRequest) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
// SessionUser.role is the database enum ("ADMIN"), not the snapshot's display form ("Admin").
if (user.role !== "ADMIN") return NextResponse.json({ error: "Admin only" }, { status: 403 });
const cursor = readCursor(req.nextUrl.searchParams.get("before"));
const rows = await prisma.auditEvent.findMany({
where: {
facilityId: user.facilityId,
// Everything strictly older, plus the rest of the millisecond we stopped in the middle of.
...(cursor ? { OR: [{ at: { lt: cursor.at } }, { at: cursor.at, id: { lt: cursor.id } }] } : {}),
},
orderBy: [{ at: "desc" }, { id: "desc" }],
take: PAGE + 1,
select: { id: true, at: true, userName: true, op: true, target: true },
});
const more = rows.length > PAGE;
const page = rows.slice(0, PAGE);
const last = page[page.length - 1];
return NextResponse.json({
events: page.map((r) => ({ id: r.id, at: r.at.toISOString(), who: r.userName, op: r.op, target: r.target })),
nextBefore: more && last ? `${last.at.toISOString()}|${last.id}` : null,
});
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { COMMUNITY } from "@/lib/edition";
import { SOURCE_URL, editionVersion } from "@/lib/source";
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. */
export async function GET() {
return NextResponse.json({
product: "threadcount",
edition: COMMUNITY ? "community" : "hosted",
version: editionVersion(),
// The AGPL's offer of source, for an app that wants to show it. Null on the hosted service,
// which is not licensed under the AGPL.
source: COMMUNITY ? SOURCE_URL : null,
paths: { counter: "/m", staff: "/my" },
// The oldest Play versionCode of each app this server still serves correctly.
minApp: { counter: 9, staff: 7 },
}, { headers: { "cache-control": "no-store", "access-control-allow-origin": "*" } });
}
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { cookies } from "next/headers";
import { REMEMBER_MAX_AGE, pwVersion, setSessionCookie } from "@/lib/session";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { decryptSecret, hashRecoveryCode, totpVerify } from "@/lib/totp";
import { TRUST_COOKIE, TRUST_TTL_MS, mintTrust, readTicket } from "@/lib/twofactor";
import { recordAuthEvent } from "@/lib/audit";
export const dynamic = "force-dynamic";
/* Second step of sign-in: the code from the authenticator, or one recovery code.
*
* Rate limited hard. A six-digit code is one in a million per guess, which is only meaningful if
* guessing is expensive — unthrottled, a million tries is minutes of work. */
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const ip = clientIp(req.headers);
let body: { ticket?: unknown; code?: unknown; trust?: unknown; remember?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const t = readTicket(String(body.ticket ?? ""));
if (!t) return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 });
// Per account and per address: one stolen ticket can't be brute-forced, and one machine can't
// work through several accounts at once.
if (!allow("2fa-user:" + t.uid, 10, 15 * 60 * 1000) || !allow("2fa-ip:" + ip, 300, 15 * 60 * 1000)) {
return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 });
}
const u = await prisma.user.findUnique({
where: { id: t.uid },
select: { id: true, facilityId: true, email: true, first: true, last: true, role: true, inactive: true, passwordHash: true, totpSecret: true, totpEnabledAt: true },
});
if (!u || u.inactive || !u.totpEnabledAt) {
return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 });
}
// The password changed between the two steps — the ticket is stale for the same reason a session
// would be.
if (pwVersion(u.passwordHash) !== t.pv) {
return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 });
}
const raw = String(body.code ?? "").trim();
const secret = decryptSecret(u.totpSecret);
let good = !!secret && totpVerify(secret, raw);
let usedRecovery = false;
if (!good && raw.replace(/[^A-Za-z0-9]/g, "").length >= 10) {
// A recovery code. Single use: consumed in the same conditional update that finds it, so two
// simultaneous attempts can't both spend it.
const hash = hashRecoveryCode(raw);
const hit = await prisma.recoveryCode.findFirst({ where: { userId: u.id, codeHash: hash, usedAt: null }, select: { id: true } });
if (hit) {
const consumed = await prisma.recoveryCode.updateMany({ where: { id: hit.id, usedAt: null }, data: { usedAt: new Date() } });
good = consumed.count === 1;
usedRecovery = good;
}
}
if (!good) return NextResponse.json({ error: "That code isn't right. Try the current one from your app." }, { status: 401 });
await setSessionCookie(u.id, u.passwordHash, false, body.remember === true ? REMEMBER_MAX_AGE : undefined);
// "Trust this computer": only ever set here, after a real code, never from the password step.
if (body.trust === true) {
const jar = await cookies();
jar.set(TRUST_COOKIE, mintTrust(u.id, pwVersion(u.passwordHash)), { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/api/auth", maxAge: Math.floor(TRUST_TTL_MS / 1000) });
}
// How they got in matters more here than anywhere else: a recovery code means the phone is gone,
// and a run of them means something else is going on.
recordAuthEvent(
{ facilityId: u.facilityId, userId: u.id, userName: `${u.first} ${u.last}`.trim() || u.email },
"auth:signin", ip, usedRecovery ? "recovery" : "totp",
);
const left = await prisma.recoveryCode.count({ where: { userId: u.id, usedAt: null } });
return NextResponse.json({ ok: true, name: `${u.first} ${u.last}`, role: u.role, usedRecovery, recoveryLeft: left });
}
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { allow, clientIp, fail, over } from "@/lib/ratelimit";
import { sameOriginJson } from "@/lib/csrf";
import { verifyTurnstile } from "@/lib/turnstile";
import { sendTo, transactionalConfigured } from "@/lib/mail";
import { RESET_TTL_MS, newResetToken, resetEmail, resetUrl } from "@/lib/reset";
export const dynamic = "force-dynamic";
/* Request a password reset.
*
* Before this existed a facility whose only admin forgot their password was locked out for good —
* the sign-in screen told them to ask an admin, and they were the admin. Deleting the last admin
* deletes the whole facility, so there was no way back in at all.
*
* The response is identical whether or not the address has an account. Anything else turns this
* into a way to ask "does this hospital use ThreadCount, and is this person a coordinator there?"
*/
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const ip = clientIp(req.headers);
let body: { email?: unknown; cfToken?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160);
// A slow ceiling per IP, so one machine can't walk a staff list to find out which addresses exist
// by watching how long each request takes. The per-address ceiling deliberately lives further
// down, past the bot check — see the note beside it.
if (!allow("forgot-ip:" + ip, 60, 60 * 60 * 1000)) {
return NextResponse.json({ ok: true });
}
const cfErr = await verifyTurnstile(body.cfToken, ip);
if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ ok: true });
const user = await prisma.user.findUnique({ where: { email }, select: { id: true, first: true, inactive: true, ssoBreakGlass: true, facility: { select: { ssoRequired: true } } } });
// A facility that requires single sign-on has no password door for most of its people, so a
// reset link would be a way round its identity provider. Break-glass admins keep theirs. The
// answer to the caller is the same either way.
const ssoOnly = !!user && user.facility.ssoRequired && !user.ssoBreakGlass;
if (user && !user.inactive && !ssoOnly) {
// The per-address ceiling counts mail actually sent, not requests received, and it is only
// consulted once the bot check has passed. Spent on requests, it handed a stranger a way to
// hold a facility's only coordinator out of their own account: four anonymous posts with no
// Turnstile token filled the bucket, and every later attempt by the coordinator was answered
// with "a reset link is on its way" and no mail. Counted this way the only way to exhaust the
// budget is to have four reset mails delivered to that same inbox, so whoever forgot their
// password always has a working link waiting for them.
const mailKey = "forgot-email:" + email;
if (over(mailKey, 4, 60 * 60 * 1000)) {
console.warn("[forgot] four reset mails already sent this hour — suppressing another for user", user.id);
} else {
const { token, tokenHash } = newResetToken();
await prisma.$transaction(async (tx) => {
// Asking again supersedes anything outstanding, so a forwarded older email goes dead.
await tx.passwordReset.updateMany({
where: { userId: user.id, usedAt: null },
data: { usedAt: new Date() },
});
await tx.passwordReset.create({
data: { userId: user.id, tokenHash, expiresAt: new Date(Date.now() + RESET_TTL_MS), requestIp: ip },
});
});
const { subject, text, html } = resetEmail(user.first, resetUrl(token));
const sent = await sendTo(email, subject, text, html);
// Only a mail that left the building counts. A send that failed gave the coordinator nothing,
// so charging them for it would shut them out for an hour over a mail outage.
if (sent) fail(mailKey, 60 * 60 * 1000);
else console.error("[forgot] reset requested but mail could not be sent for user", user.id);
}
}
// Told to the caller regardless, so the answer carries no information about the address.
return NextResponse.json({ ok: true, mail: transactionalConfigured() });
}
+103
View File
@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
import { REMEMBER_MAX_AGE, pwVersion, setSessionCookie } from "@/lib/session";
import { TRUST_COOKIE, mintTicket, readTrust } from "@/lib/twofactor";
import { sameOriginJson } from "@/lib/csrf";
import { clientIp, fail, over } from "@/lib/ratelimit";
import { signInStaff } from "@/lib/staffauth";
import { verifyTurnstile } from "@/lib/turnstile";
import { recordAuthEvent } from "@/lib/audit";
export const dynamic = "force-dynamic";
// Simple in-memory throttle per IP+email (per process).
const attempts = new Map<string, { n: number; t: number }>();
/** The trail names the person, not the address they typed — see lib/audit.ts. */
const actorFor = (u: { id: string; facilityId: string; first: string; last: string; email: string }) =>
({ facilityId: u.facilityId, userId: u.id, userName: `${u.first} ${u.last}`.trim() || u.email });
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
let body: { email?: string; password?: string; cfToken?: string; remember?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const email = String(body.email || "").trim().toLowerCase().slice(0, 160);
const password = String(body.password || "").slice(0, 200);
// Spray protection independent of the per-(ip,email) counter below. Both buckets count only the
// attempts that FAILED — a whole hospital signs in from one NAT address at shift change, and a
// ceiling on attempts would have to lock that ward out to be worth anything against an attacker.
const ipKey = clientIp(req.headers);
if (over("login-ip:" + ipKey, 40, 15 * 60 * 1000) || (email && over("login-email:" + email, 25, 15 * 60 * 1000))) return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 });
if (!email || !password) return NextResponse.json({ error: "Enter your email and password." }, { status: 400 });
// nginx appends the real client IP last; earlier entries are client-supplied and spoofable.
const xff = req.headers.get("x-forwarded-for")?.split(",").map((x) => x.trim()).filter(Boolean) || [];
const ip = xff[xff.length - 1] || "local";
if (attempts.size > 5000) for (const [kk, v] of attempts) if (Date.now() - v.t > 15 * 60 * 1000) attempts.delete(kk);
const k = `${ip}|${email}`;
const a = attempts.get(k);
if (a && a.n >= 8 && Date.now() - a.t < 15 * 60 * 1000) return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 });
const cfErr = await verifyTurnstile(body.cfToken, ipKey); if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
const u = await prisma.user.findUnique({ where: { email } });
/* One box, both kinds of account.
*
* A wearer reaches the product the way anyone else does — the home page, then Log in — and types
* the details they set up in the staff app. So when this address has no coordinator account, the
* register is asked before the answer is called wrong.
*
* A coordinator account always wins: it is the one with the counter, the orders and the register
* behind it, and a coordinator who also wears a uniform can open their own record from inside the
* app. One address therefore has one destination, every time.
*
* This is a lookup, not a second attempt. "Try the coordinator, and if that fails try the staff
* one" would score a failure against every single staff sign-in, and these ceilings count
* failures — behind one hospital's NAT address at shift change that is a locked-out ward.
*/
if (!u) {
const s = await signInStaff(email, password, ipKey, false);
if (s.kind === "ok") return NextResponse.json({ ok: true, name: s.name, staff: true });
if (s.kind === "error") return NextResponse.json({ error: s.error }, { status: s.status });
// `none`: no staff account either, so this falls through to the answer below, which counts the
// failure once and says the same thing it has always said.
}
const ok = u ? await bcrypt.compare(password, u.passwordHash) : await bcrypt.compare(password, "$2b$12$C6UzMDM.H6dfI/f/IKcEeO5x3FvDS3kqB6r0Jt3g7Lz0vX4o0JZ1u");
if (!u || !ok) {
attempts.set(k, { n: (a && Date.now() - a.t < 15 * 60 * 1000 ? a.n : 0) + 1, t: Date.now() });
fail("login-ip:" + ipKey, 15 * 60 * 1000);
if (email) fail("login-email:" + email, 15 * 60 * 1000);
// An address with no account here is recorded nowhere: there is no facility to file it under,
// and a log of attempts on addresses that don't exist would be a list of other people's email
// addresses that nobody asked us to keep.
if (u) recordAuthEvent(actorFor(u), "auth:signin.failed", ipKey);
return NextResponse.json({ error: "Email or password doesnt match." }, { status: 401 });
}
attempts.delete(k);
if (u.inactive) {
// The right password on an account that has been taken away is worth knowing about.
recordAuthEvent(actorFor(u), "auth:signin.refused", ipKey, "inactive");
return NextResponse.json({ error: "This account has been deactivated. Ask an admin at your facility to reactivate it." }, { status: 403 });
}
const fac = await prisma.facility.findUnique({ where: { id: u.facilityId }, select: { isDemo: true, ssoEnabled: true, ssoRequired: true } });
if (fac?.isDemo) return NextResponse.json({ error: "Demo accounts cant log in here — open the demo from the home page." }, { status: 403 });
// The facility has decided its people sign in through its own identity provider. The password
// was right, and it is still refused — except for the admin the facility keeps as its fire
// escape. The box sends them on to single sign-on rather than reporting a failure.
if (fac?.ssoEnabled && fac.ssoRequired && !u.ssoBreakGlass) {
recordAuthEvent(actorFor(u), "auth:signin.refused", ipKey, "sso required");
return NextResponse.json({ error: "Your facility signs in with single sign-on.", ssoRequired: true }, { status: 403 });
}
// With a second factor on the account the password alone opens nothing. The ticket says only
// "this password was correct", is accepted by no other endpoint, and expires in five minutes.
// A browser that entered a code within the last thirty days and asked to be trusted skips it;
// the trust token is bound to the password version, so a changed password asks again.
const trusted = !!u.totpEnabledAt && readTrust(req.cookies.get(TRUST_COOKIE)?.value, u.id, pwVersion(u.passwordHash));
if (u.totpEnabledAt && !trusted) {
return NextResponse.json({ need2fa: true, ticket: mintTicket(u.id, pwVersion(u.passwordHash)) });
}
await setSessionCookie(u.id, u.passwordHash, false, body.remember === true ? REMEMBER_MAX_AGE : undefined);
recordAuthEvent(actorFor(u), "auth:signin", ipKey, trusted ? "password+trusted" : "password");
return NextResponse.json({ ok: true, name: `${u.first} ${u.last}`, role: u.role });
}
+22
View File
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { clearSessionCookie, currentUser } from "@/lib/session";
import { sameOriginJson } from "@/lib/csrf";
import { clientIp } from "@/lib/ratelimit";
import { recordAuthEvent } from "@/lib/audit";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req, false); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
// Read the session before dropping it, so the trail can say who left. An unauthenticated call
// still clears the cookie and still answers ok — signing out must never fail.
const user = await currentUser();
await clearSessionCookie();
if (user) {
recordAuthEvent(
{ facilityId: user.facilityId, userId: user.id, userName: `${user.first} ${user.last}`.trim() || user.email },
"auth:signout", clientIp(req.headers),
);
}
return NextResponse.json({ ok: true });
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
export const dynamic = "force-dynamic";
/* Which door does this address belong at?
*
* The sign-in screen asks for the address first and only then shows a password box, a single
* sign-on button or a pointer to the staff app. This answers the last of those: an address that
* has no coordinator account but does have a staff-app account belongs in the staff app, and
* telling the person so beats a "wrong password" they can never get past. It answers nothing about
* coordinator accounts — a coordinator address and an unknown address get the same reply, so the
* box cannot be used to test which addresses have one. Throttled per connection like the SSO lookup. */
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
if (!allow("lookup:" + clientIp(req.headers), 60, 15 * 60 * 1000)) return NextResponse.json({ staff: false });
let body: { email?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160);
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ staff: false });
const user = await prisma.user.findUnique({ where: { email }, select: { id: true } });
if (user) return NextResponse.json({ staff: false });
const acc = await prisma.staffAccount.findUnique({ where: { email }, select: { id: true } });
return NextResponse.json({ staff: !!acc });
}
+96
View File
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
import { allow, clientIp } from "@/lib/ratelimit";
import { sameOriginJson } from "@/lib/csrf";
import { pwVersion, setSessionCookie } from "@/lib/session";
import { mintTicket } from "@/lib/twofactor";
import { hashResetToken } from "@/lib/reset";
import { recordAuthEvent } from "@/lib/audit";
export const dynamic = "force-dynamic";
const MIN_PASSWORD = 8;
/* Complete a password reset.
*
* Changing the hash invalidates every existing session for that user on its own — the session
* cookie carries a version derived from the password hash — so a reset also kicks out whoever
* prompted it, which is the behaviour you want if the reason was a shared or stolen password. */
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const ip = clientIp(req.headers);
if (!allow("reset-ip:" + ip, 100, 60 * 60 * 1000)) {
return NextResponse.json({ error: "Too many attempts — try again later." }, { status: 429 });
}
let body: { token?: unknown; password?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const token = String(body.token ?? "").trim().slice(0, 400);
const password = String(body.password ?? "");
if (!token) return NextResponse.json({ error: "That link is incomplete. Ask for a new one." }, { status: 400 });
if (password.length < MIN_PASSWORD) {
return NextResponse.json({ error: `Use at least ${MIN_PASSWORD} characters.` }, { status: 400 });
}
// Looked up by hash, so the raw token never has to be compared against stored material.
const row = await prisma.passwordReset.findUnique({
where: { tokenHash: hashResetToken(token) },
select: {
id: true, userId: true, expiresAt: true, usedAt: true,
user: { select: { inactive: true, passwordHash: true, totpEnabledAt: true, facilityId: true, first: true, last: true, email: true } },
},
});
const dead = !row || row.usedAt || row.expiresAt.getTime() < Date.now() || row.user.inactive;
if (dead) {
return NextResponse.json({ error: "That link has expired or has already been used. Ask for a new one." }, { status: 400 });
}
const hash = await bcrypt.hash(password, 12);
await prisma.$transaction(async (tx) => {
// Consume the token in the same write as the password change, so a double submit can't set the
// password twice or leave a live token behind.
const consumed = await tx.passwordReset.updateMany({
where: { id: row.id, usedAt: null },
data: { usedAt: new Date() },
});
if (consumed.count !== 1) throw new Error("token already consumed");
await tx.user.update({ where: { id: row.userId }, data: { passwordHash: hash } });
// Any other outstanding requests for this account die with it.
await tx.passwordReset.updateMany({ where: { userId: row.userId, usedAt: null }, data: { usedAt: new Date() } });
}).catch(() => null);
const fresh = await prisma.user.findUnique({ where: { id: row.userId }, select: { passwordHash: true } });
if (!fresh || fresh.passwordHash !== hash) {
return NextResponse.json({ error: "That link has expired or has already been used. Ask for a new one." }, { status: 400 });
}
const actor = {
facilityId: row.user.facilityId,
userId: row.userId,
userName: [row.user.first, row.user.last].filter(Boolean).join(" ").trim() || row.user.email,
};
// A second factor is a second factor here too. Control of the mailbox is one proof, and on an
// account with TOTP the front door refuses to open on one proof — so this door must not either,
// or resetting the password would be the supported way around the authenticator, and the new
// password would then be enough to turn it off for good.
//
// The same five-minute ticket the sign-in screen uses, accepted by the same endpoint: nothing new
// to keep, nothing new to get wrong.
if (row.user.totpEnabledAt) {
recordAuthEvent(actor, "auth:password.reset", ip, "email-link");
return NextResponse.json({ need2fa: true, ticket: mintTicket(row.userId, pwVersion(hash)) });
}
// Otherwise sign them straight in: they have just proven control of the mailbox and chosen a
// password, and making them type it again immediately is friction with no security value.
await setSessionCookie(row.userId, hash);
recordAuthEvent(actor, "auth:password.reset", ip, "email-link");
recordAuthEvent(actor, "auth:signin", ip, "reset");
return NextResponse.json({ ok: true });
}
+100
View File
@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
import { setSessionCookie } from "@/lib/session";
import { allow, clientIp } from "@/lib/ratelimit";
import { sameOriginJson } from "@/lib/csrf";
import { verifyTurnstile } from "@/lib/turnstile";
import { sendTo, transactionalConfigured } from "@/lib/mail";
import { switches } from "@/lib/switches";
import { alertNewSignup } from "@/lib/ops/alerts";
import { recordAuthEvent } from "@/lib/audit";
import { TRIAL_DAYS } from "@/lib/plan";
import { sendBillingMail, templates } from "@/lib/billing-mail";
import { welcomeEmail } from "@/lib/accountmail";
export const dynamic = "force-dynamic";
/* Starting staff groups by healthcare setting. Generic titles only — every room renames them. */
const GROUP_SEEDS: Record<string, { staffGroups: string[]; nursingGroups: string[]; kitGroups: string[] }> = {
hospital: { staffGroups: ["Registered Nurse", "Enrolled Nurse", "Allied Health", "Support Services", "Security"], nursingGroups: ["Registered Nurse", "Enrolled Nurse"], kitGroups: ["Support Services"] },
aged_care: { staffGroups: ["Registered Nurse", "Enrolled Nurse", "Personal Care Worker", "Hospitality", "Maintenance"], nursingGroups: ["Registered Nurse", "Enrolled Nurse"], kitGroups: ["Hospitality", "Maintenance"] },
community: { staffGroups: ["Registered Nurse", "Enrolled Nurse", "Allied Health", "Administration"], nursingGroups: ["Registered Nurse", "Enrolled Nurse"], kitGroups: ["Administration"] },
};
const STATE_ZONES: Record<string, string> = {
QLD: "Australia/Brisbane", NSW: "Australia/Sydney", ACT: "Australia/Sydney", VIC: "Australia/Melbourne", TAS: "Australia/Hobart",
SA: "Australia/Adelaide", NT: "Australia/Darwin", WA: "Australia/Perth", NZ: "Pacific/Auckland",
};
/* Creating a facility.
*
* The address typed here is not verified, and deliberately isn't: a confirmation step in front of
* a linen room's first ten minutes is a wall, and a facility half-created behind an unclicked link
* is worse than one created. But it is the *only* way back in — /api/auth/forgot answers a
* stranger and the owner identically, so a typo produces no signal at all until the day the
* password is forgotten, and by then the facility is unreachable and undeletable.
*
* So the address is exercised immediately instead. A note goes to it saying, in as many words,
* that this is the address that recovers the account, and the answer here says whether it was
* sent — which is what lets the sign-up screen show the address back and tell someone who never
* receives it what to do about it while they are still signed in and can still act.
*/
export async function POST(req: NextRequest) {
const sw = await switches();
if (!sw.signupsOpen) return NextResponse.json({ error: "New facility sign-ups are closed." }, { status: 403 });
const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
if (!allow("signup:" + clientIp(req.headers), 5, 60 * 60 * 1000)) return NextResponse.json({ error: "Too many sign-ups from this connection — try again later." }, { status: 429 });
let b: Record<string, string>;
try { b = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const first = String(b.first || "").trim().slice(0, 80), last = String(b.last || "").trim().slice(0, 80);
const facility = String(b.facility || "").trim().slice(0, 120);
const email = String(b.email || "").trim().toLowerCase().slice(0, 160);
const password = String(b.password || "");
if (!first || !last || !facility) return NextResponse.json({ error: "Name and facility are required." }, { status: 400 });
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ error: "Enter a valid work email." }, { status: 400 });
if (password.length < 8) return NextResponse.json({ error: "Password must be at least 8 characters." }, { status: 400 });
const cfErr = await verifyTurnstile(b.cfToken, clientIp(req.headers)); if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
if (await prisma.user.findUnique({ where: { email } })) return NextResponse.json({ error: "That email already has an account — log in instead." }, { status: 409 });
const u = await prisma.$transaction(async (tx) => {
// No staff groups: the facility names its own. Any list handed over here would be one employer's
// organisation chart on another employer's register, and a group sitting on a route nobody
// chose decides who is handed a starting kit. Both route lists start empty with it, so until the
// coordinator puts a group on the FTE table or the starting kit, everybody is on manager approval
// and nobody has been promised a kit the counter would not hand over.
// Until plans are live the page still says free, so a facility created today is grandfathered:
// free with everything, for good. Once they are live a new room starts on the plan it chose —
// Hosted Small, free, or a Hosted Facility trial with its end date set now. Anything else
// sent as `plan` is Hosted Small: the free room is the safe misreading.
const trial = sw.plansLive && b.plan === "hosted_facility";
const planData = !sw.plansLive
? { grandfathered: true, planStatus: "free" }
: trial
? { plan: "hosted_facility", planStatus: "trial", trialEndsAt: new Date(Date.now() + TRIAL_DAYS * 86_400_000) }
: { plan: "hosted_small", planStatus: "free" };
// Two optional answers from the sign-up screen. The setting seeds the staff groups the room
// starts with (renamed or removed freely under Settings); the state sets the time zone counts
// and month-end are read in. Neither is required, and "other"/blank leaves the old defaults.
const seed = GROUP_SEEDS[String(b.setting || "")] || {};
const timezone = STATE_ZONES[String(b.state || "").toUpperCase()];
const f = await tx.facility.create({ data: { name: facility, coordinator: `${first} ${last}`, ...planData, ...seed, ...(timezone ? { timezone } : {}) } });
return tx.user.create({ data: { facilityId: f.id, email, passwordHash: await bcrypt.hash(password, 12), first, last, title: "Uniform Coordinator", role: "ADMIN" } });
});
await setSessionCookie(u.id, u.passwordHash);
recordAuthEvent({ facilityId: u.facilityId, userId: u.id, userName: `${first} ${last}`.trim() || email }, "auth:signup", clientIp(req.headers));
alertNewSignup({ id: u.facilityId, name: facility }); // the facility's name only — never the person
const em = welcomeEmail(first, facility);
const mailed = await sendTo(email, em.subject, em.text, em.html);
// A room on a trial also gets the trial letter: what the 30 days include, when they end, and
// that no card was taken. Not awaited — the welcome above is the one sign-up waits for.
void (async () => {
const f = await prisma.facility.findUnique({ where: { id: u.facilityId }, select: { planStatus: true, trialEndsAt: true } });
if (f?.planStatus === "trial" && f.trialEndsAt) await sendBillingMail(u.facilityId, (ctx) => templates.trialStarted(ctx, { first, endsAt: f.trialEndsAt }));
})();
if (!mailed && transactionalConfigured()) console.error("[signup] welcome mail could not be sent for user", u.id);
// `mailed` is false when no SMTP is configured at all, which is a different thing from a bad
// address — the screen says so rather than pretending the address has been proven.
return NextResponse.json({ ok: true, email, mailed, mail: transactionalConfigured() });
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { currentUser } from "@/lib/session";
import { prisma } from "@/lib/db";
import { exportBackup } from "@/lib/ops";
import { facilityToday } from "@/lib/compute";
export const dynamic = "force-dynamic";
export async function GET() {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
if (user.role !== "ADMIN") return NextResponse.json({ error: "Admin only" }, { status: 403 });
const data = await exportBackup(user);
// The date on the filename is the day where the linen room stands, not where the box is. It has
// to agree with the lastBackup stamp exportBackup writes against the same facility, or a room
// taking a backup at eight in the morning ends up with a file named for yesterday sitting beside
// a settings screen that says it was taken today.
const fac = await prisma.facility.findUniqueOrThrow({ where: { id: user.facilityId }, select: { timezone: true } });
return new NextResponse(JSON.stringify(data, null, 1), {
headers: {
"content-type": "application/json; charset=utf-8",
"content-disposition": `attachment; filename="threadcount-backup-${facilityToday(fac.timezone)}.json"`,
},
});
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
/* Is this server actually able to do its job?
*
* The deploy probes /app, which proves the process is serving HTML — but /app renders a redirect to
* the sign-in page whether or not Prisma can reach the database, so the one failure that takes the
* whole product down is exactly the one that probe cannot see. This asks the database a question
* instead, and answers 503 when it cannot.
*
* No auth and no cache on purpose: it is watched continuously by an uptime monitor with no account,
* and it must never answer from a cached success. It is listed in proxy.ts's `publicApi` for the
* same reason. Nothing about the facility, the schema or the error is returned — a monitor needs a
* status code, and an unauthenticated caller is owed nothing more.
*/
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({ ok: true }, { headers: { "cache-control": "no-store" } });
} catch (e) {
console.error("[health] database unreachable:", (e as Error).message);
return NextResponse.json({ ok: false }, { status: 503, headers: { "cache-control": "no-store" } });
}
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { currentUser } from "@/lib/session";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
/** Serves the signed-in user's facility logo (stored as a data URL). */
export async function GET() {
const user = await currentUser();
if (!user) return new NextResponse(null, { status: 401 });
const fac = await prisma.facility.findUnique({ where: { id: user.facilityId }, select: { logoData: true } });
const m = /^data:(image\/(?:png|jpeg|jpg|gif|webp));base64,([A-Za-z0-9+/=]+)$/.exec(fac?.logoData || "");
if (!m) return new NextResponse(null, { status: 404 });
return new NextResponse(Buffer.from(m[2], "base64"), { headers: { "content-type": m[1], "cache-control": "private, no-cache", "x-content-type-options": "nosniff", "content-security-policy": "sandbox" } });
}
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/session";
import { allow } from "@/lib/ratelimit";
import { gtinInfo } from "@/lib/compute";
export const dynamic = "force-dynamic";
export type LookupResult = {
code: string;
gtin: ReturnType<typeof gtinInfo>;
enabled: boolean; // is public lookup turned on for this facility
found: boolean;
name?: string;
brand?: string;
category?: string;
source?: string;
note?: string; // why there's no result, in plain words
};
const TIMEOUT_MS = 4500;
const cache = new Map<string, { at: number; v: Omit<LookupResult, "enabled" | "gtin" | "code"> }>();
const CACHE_MS = 12 * 60 * 60 * 1000;
async function getJson(url: string): Promise<unknown | null> {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), TIMEOUT_MS);
try {
const r = await fetch(url, { signal: ac.signal, headers: { accept: "application/json", "user-agent": "ThreadCount/1.0 (uniform stock management)" }, cache: "no-store" });
if (!r.ok) return null;
return await r.json();
} catch { return null; } finally { clearTimeout(t); }
}
/** UPCitemdb's keyless trial tier — small daily quota per server IP, so misses are expected. */
async function upcItemDb(gtin: string) {
const j = await getJson(`https://api.upcitemdb.com/prod/trial/lookup?upc=${encodeURIComponent(gtin)}`) as { items?: { title?: string; brand?: string; category?: string }[] } | null;
const it = j?.items?.[0];
if (!it?.title) return null;
return { name: String(it.title).slice(0, 160), brand: String(it.brand || "").slice(0, 80), category: String(it.category || "").slice(0, 80), source: "UPCitemdb" };
}
/** Open Products Facts — the non-food sibling of Open Food Facts; open data, no key. */
async function openProductsFacts(gtin: string) {
const j = await getJson(`https://world.openproductsfacts.org/api/v2/product/${encodeURIComponent(gtin)}.json?fields=product_name,brands,categories`) as { status?: number; product?: { product_name?: string; brands?: string; categories?: string } } | null;
const pr = j?.product;
if (j?.status !== 1 || !pr?.product_name) return null;
return { name: String(pr.product_name).slice(0, 160), brand: String(pr.brands || "").slice(0, 80), category: String(pr.categories || "").slice(0, 80), source: "Open Products Facts" };
}
export async function GET(req: NextRequest) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
if (user.role !== "ADMIN") return NextResponse.json({ error: "Admin only" }, { status: 403 });
const gtin = gtinInfo(req.nextUrl.searchParams.get("code") || "");
const base = { code: gtin.code, gtin, found: false } as LookupResult;
if (!gtin.code) return NextResponse.json({ ...base, enabled: false, note: "No barcode given." });
const fac = await prisma.facility.findUnique({ where: { id: user.facilityId }, select: { barcodeLookup: true } });
const enabled = !!fac?.barcodeLookup;
if (!enabled) return NextResponse.json({ ...base, enabled: false, note: "Product lookup is off. Turn it on in Settings → Data if you want ThreadCount to ask a public barcode database for a name." });
// Only real retail GTINs are worth sending anywhere; a mis-read or an in-house code never matches.
if (!gtin.valid || !["EAN-13", "UPC-A", "EAN-8", "GTIN-14"].includes(gtin.kind)) {
return NextResponse.json({ ...base, enabled, note: gtin.kind ? "The check digit doesn't match, so this wasn't looked up — scan it again." : "Not a standard retail barcode, so there's nothing to look up. Type the details in." });
}
if (!allow("lookup:" + user.facilityId, 120, 60 * 60 * 1000)) return NextResponse.json({ ...base, enabled, note: "Too many lookups this hour — type the details in for now." }, { status: 429 });
const hit = cache.get(gtin.digits);
if (hit && Date.now() - hit.at < CACHE_MS) return NextResponse.json({ ...base, enabled, ...hit.v });
let found = await upcItemDb(gtin.digits);
if (!found) found = await openProductsFacts(gtin.digits);
const v = found
? { found: true, ...found }
: { found: false, note: "No public listing for this barcode — normal for workwear and hospital uniforms. Type the details in once and the barcode stays bound." };
cache.set(gtin.digits, { at: Date.now(), v });
if (cache.size > 500) for (const k of [...cache.keys()].slice(0, 100)) cache.delete(k);
return NextResponse.json({ ...base, enabled, ...v });
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { currentUser } from "@/lib/session";
import { OpError, bumpRev, demoGuard, restoreBackup, runOp } from "@/lib/ops";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { recordAudit } from "@/lib/audit";
import { report } from "@/lib/glitchtip";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
if (parseInt(req.headers.get("content-length") || "0", 10) > 60 * 1024 * 1024) return NextResponse.json({ error: "Request too large" }, { status: 413 });
let body: { op?: string; payload?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad JSON" }, { status: 400 }); }
const op = String(body.op || "");
if (!allow("mutate:" + user.id, 600, 60 * 1000)) return NextResponse.json({ error: "Slow down — too many changes in a minute." }, { status: 429 });
if (op === "photo.put" && !allow("photo:" + user.facilityId, 120, 60 * 60 * 1000)) return NextResponse.json({ error: "Photo limit reached for this hour." }, { status: 429 });
if ((op === "backup.restore" || op === "import.rows") && !allow("bulk:" + user.id, 20, 10 * 60 * 1000)) return NextResponse.json({ error: "Too many imports — wait a few minutes." }, { status: 429 });
try {
if (op === "backup.restore") demoGuard(user, op);
const result = op === "backup.restore" ? await restoreBackup(user, body.payload) : await runOp(user, op, body.payload);
// Only after it actually succeeded, and only from here: every one of the 57 ops passes through
// this one function, so the trail can't be forgotten in a new case branch later.
recordAudit(user, op, body.payload, clientIp(req.headers));
// Handed back so the screen that made this change does not bounce again when it next polls.
const rev = await bumpRev(user.facilityId);
return NextResponse.json({ ok: true, result, rev });
} catch (e) {
if (e instanceof OpError) return NextResponse.json({ error: e.message }, { status: e.status });
// Reported from here, not from instrumentation.ts: onRequestError only sees what Next itself
// catches, and an exception caught in this handler never reaches it. Every write in the product
// comes through this line, so without it the whole write path fails invisibly.
report({ error: e, where: "server", url: "/api/mutate", tags: { op } });
console.error(`[mutate ${op}]`, e);
return NextResponse.json({ error: "Something went wrong — nothing was saved." }, { status: 500 });
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/session";
import { parseDataUrl, readPhoto } from "@/lib/photostore";
export const dynamic = "force-dynamic";
/* Serves a stored capture or signature to a signed-in user of the same facility.
*
* Images live on disk now; rows written before that move still carry a base64 data URL, so both
* are handled and old records keep working without a flag day. */
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const { id } = await ctx.params;
// Scoped by facility in the query: a photo id from another room is simply not found.
const ph = await prisma.photo.findFirst({
where: { id, facilityId: user.facilityId },
select: { data: true, path: true, mime: true },
});
if (!ph) return NextResponse.json({ error: "Not found" }, { status: 404 });
let mime = ph.mime;
let bytes: Buffer | null = null;
if (ph.path) {
bytes = await readPhoto(ph.path);
} else if (ph.data) {
const parsed = parseDataUrl(ph.data);
if (parsed) { mime = parsed.mime; bytes = parsed.bytes; }
}
if (!bytes) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!/^image\/(jpeg|png)$/.test(mime)) return NextResponse.json({ error: "Bad photo" }, { status: 500 });
return new NextResponse(new Uint8Array(bytes), {
headers: {
"content-type": mime,
"cache-control": "private, max-age=3600",
"content-disposition": "inline",
"x-content-type-options": "nosniff",
"content-security-policy": "sandbox",
},
});
}
+220
View File
@@ -0,0 +1,220 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/session";
import { bagLines, linesSummary, reqLines } from "@/lib/staffdata";
import { decisionSummary, garmentCount } from "@/lib/staffreq";
export const dynamic = "force-dynamic";
/* The linen room's view of staff requests.
*
* Its own endpoint rather than part of the snapshot, for the same reason the audit trail is: this
* grows without limit, and putting it in the snapshot would make every page in the app heavier
* forever to serve one screen.
*/
export async function GET(req: NextRequest) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
/* One person's requests, or the whole facility's.
*
* `?staff=` is how a staff record asks for its own order-form history. Without it that screen
* pulled the facility's last 400 requests — every line, message and event on each — and kept the
* handful belonging to one person, which is a large answer to a small question on a busy
* register. Worse, that person's older requests fell off the end of the 400 and simply were not
* on their record any more. Scoped, the ceiling is per person, and either way it is reported
* back so a screen can say it has been reached rather than ending a history without a word.
*/
const staffId = (req.nextUrl.searchParams.get("staff") || "").trim().slice(0, 64);
const requestLimit = staffId ? 200 : 400;
// One more row than is returned, so "there are older ones than these" is something we know
// rather than something guessed from a full page.
const found = await prisma.request.findMany({
where: { facilityId: user.facilityId, ...(staffId ? { subjectId: staffId } : {}) },
orderBy: { createdAt: "desc" },
take: requestLimit + 1,
include: {
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
subject: { select: { first: true, last: true, num: true, dept: true } },
messages: { orderBy: { createdAt: "asc" }, select: { id: true, fromStaff: true, authorName: true, body: true, createdAt: true } },
events: { orderBy: { at: "asc" }, select: { id: true, label: true, meta: true, actorName: true, at: true } },
},
});
const requests = found.slice(0, requestLimit);
const moreRequests = found.length > requestLimit;
const mapped = requests.map((r) => {
/* Every line, and separately the ones that are actually a pick.
*
* The linen room needs both. `lines` is the record — a declined fleece still belongs on the
* order the wearer will read — while `bag` is the work: what to take off the shelf, put in
* the bag and hand across the counter. Picking from `lines` would put a garment the manager
* refused into somebody's hands, so the two are never the same field. */
const lines = reqLines(r.lines);
const bag = bagLines(lines);
return {
id: r.id, code: r.code, status: r.status,
staffId: r.subjectId,
staffName: `${r.subject.first} ${r.subject.last}`.trim(),
staffNum: r.subject.num, ward: r.subject.dept,
lines, bag,
summary: linesSummary(lines), garments: garmentCount(bag), lineCount: lines.length,
decision: decisionSummary(lines),
reason: r.reason, note: r.note,
managerName: r.managerName,
/* Who the approver is, not just how their name is spelled. A manager may now approve a
* request raised for herself, and the only thing that can show that happened is this id
* beside the subject's — the name on its own would have any screen comparing two spellings
* of the same person, which is precisely how a self-approval goes unnoticed. */
managerId: r.managerId,
declineReason: r.declineReason,
route: r.route, collectCode: r.collectCode, holdUntil: r.holdUntil,
signerName: r.signerName, signerRole: r.signerRole,
signedAt: r.signedAt?.toISOString() ?? null,
claimedAt: r.claimedAt?.toISOString() ?? null,
/* Who raised it, and which person on the register that is.
*
* The name alone is not enough for the queue screen: it builds the list of people a stranded
* request can be handed to, and the one name certain to be refused is the person who raised
* it — a manager asking for one of her own reports' garments is exactly why the request
* escalated with nobody to approve it. Told only her name, the screen would have to match
* her by spelling against a ward where two people share one, which is how the wrong person
* drops out of a dropdown.
*
* Only the staff column, because only it can ever name somebody who could approve anything.
* A raise at the counter is stamped with the coordinator's own account instead, and a
* coordinator is not on the ward register at all; a wearer raising for herself is stamped
* with neither. Both arrive here as null, which is right — neither is a name this queue
* could offer. */
raisedById: r.raisedByStaffId,
raisedByName: r.raisedByName,
createdAt: r.createdAt.toISOString(),
decidedAt: r.decidedAt?.toISOString() ?? null,
messages: r.messages.map((m) => ({ id: m.id, fromStaff: m.fromStaff, authorName: m.authorName, body: m.body, at: m.createdAt.toISOString() })),
events: r.events.map((e) => ({ id: e.id, label: e.label, meta: e.meta, actorName: e.actorName, at: e.at.toISOString() })),
};
});
/* Everything below is the linen room's queue screen — open disputes, the kit check, the
* waitlist, damage nobody has handed back. A staff record asks for one person's order forms and
* reads none of it, so a scoped ask stops here instead of running four more facility-wide
* queries whose answers are thrown away. Those keys are absent from a scoped reply rather than
* empty: an empty list would read as "there are none", which nobody asked and nobody knows. */
if (staffId) return NextResponse.json({ requests: mapped, requestLimit, moreRequests });
const [disputes, cycle, waiting, damage] = await Promise.all([
prisma.recordDispute.findMany({
where: { facilityId: user.facilityId, resolvedAt: null },
orderBy: { createdAt: "desc" },
take: 100,
include: { staff: { select: { first: true, last: true, num: true, dept: true } } },
}),
prisma.kitCheck.findFirst({
where: { facilityId: user.facilityId, closedAt: null },
orderBy: { openedAt: "desc" },
select: { id: true, dueBy: true, openedAt: true, openedBy: true, _count: { select: { answers: true } } },
}),
prisma.waitlistEntry.findMany({
where: { facilityId: user.facilityId, leftAt: null, acceptedAt: null },
orderBy: { createdAt: "asc" },
include: {
staff: { select: { first: true, last: true, num: true, dept: true } },
item: { select: { item: true, sizes: true } },
},
}),
// Damage reports the counter has not yet taken the garment back for. Reporting damage and
// asking for a replacement are two separate acts in the staff app, so a report can arrive with
// no request behind it — and until this list existed nothing in the product ever showed one to
// anybody, which made the Damage screen's promise ("it comes off your record when you hand it
// in at the counter") a promise no screen could keep.
prisma.damageReport.findMany({
where: { facilityId: user.facilityId, handedInAt: null },
orderBy: { createdAt: "desc" },
take: 100,
include: {
staff: { select: { first: true, last: true, num: true, dept: true } },
issue: { select: { sizeIndex: true, item: { select: { item: true, sizes: true } } } },
},
}),
]);
/* What the open kit check has actually turned up.
*
* The cycle used to be reported to the linen room as a bare count of answers, which is the one
* thing about it that doesn't matter: nobody opens a kit check to find out how many people
* replied. The answers are the point — every one where somebody could not account for what the
* record says they hold — and until this query existed no screen, export or report in the
* product read them, so the whole cycle collected evidence into a table nothing looked at.
*
* Only the shortfalls, and only for the cycle still open. An answer that matches the record is
* the record agreeing with itself; a closed cycle is history and belongs with the rest of it.
*/
const answers = cycle
? await prisma.kitCheckAnswer.findMany({
where: { kitCheckId: cycle.id, confirmed: { lt: prisma.kitCheckAnswer.fields.onRecord } },
orderBy: { answeredAt: "desc" },
take: 400,
include: {
staff: { select: { id: true, first: true, last: true, num: true, dept: true } },
item: { select: { item: true, sizes: true } },
},
})
: [];
// DamageReport.requestId is a plain column rather than a relation, so the replacement's code is
// looked up here. It is what the linen room actually needs: "torn, and she has asked for R-0042"
// is a different job from "torn, and she has not".
const replacementCodes = new Map<string, string>();
const replacementIds = damage.map((d) => d.requestId).filter((x): x is string => !!x);
if (replacementIds.length) {
const reps = await prisma.request.findMany({
where: { facilityId: user.facilityId, id: { in: replacementIds } },
select: { id: true, code: true },
});
for (const r of reps) replacementCodes.set(r.id, r.code);
}
return NextResponse.json({
requests: mapped, requestLimit, moreRequests,
disputes: disputes.map((d) => ({
id: d.id, body: d.body,
staffName: `${d.staff.first} ${d.staff.last}`.trim(),
staffNum: d.staff.num, ward: d.staff.dept,
at: d.createdAt.toISOString(),
})),
cycle: cycle && {
id: cycle.id, dueBy: cycle.dueBy, openedBy: cycle.openedBy,
openedAt: cycle.openedAt.toISOString(), answers: cycle._count.answers,
},
shortfalls: answers.map((a) => ({
id: a.id,
staffId: a.staff.id,
staffName: `${a.staff.first} ${a.staff.last}`.trim(),
staffNum: a.staff.num, ward: a.staff.dept,
item: a.item.item, size: String(a.item.sizes[a.sizeIndex] ?? a.sizeIndex),
onRecord: a.onRecord, confirmed: a.confirmed, short: a.onRecord - a.confirmed,
at: a.answeredAt.toISOString(),
})),
waiting: waiting.map((w) => ({
id: w.id,
staffName: `${w.staff.first} ${w.staff.last}`.trim(),
staffNum: w.staff.num, ward: w.staff.dept,
item: w.item.item, size: String(w.item.sizes[w.sizeIndex] ?? w.sizeIndex),
since: w.createdAt.toISOString(),
offeredAt: w.offeredAt?.toISOString() ?? null,
})),
damage: damage.map((d) => ({
id: d.id, kind: d.kind, note: d.note, photoId: d.photoId,
staffId: d.staffId,
staffName: `${d.staff.first} ${d.staff.last}`.trim(),
staffNum: d.staff.num, ward: d.staff.dept,
// The garment comes off the Issue the report was raised against. That issue can be deleted
// (a wipe, a correction) and the column is SetNull, so an older report may name no garment.
item: d.issue ? d.issue.item.item : "",
size: d.issue ? String(d.issue.item.sizes[d.issue.sizeIndex] ?? d.issue.sizeIndex) : "",
requestCode: d.requestId ? replacementCodes.get(d.requestId) ?? "" : "",
at: d.createdAt.toISOString(),
})),
});
}
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/session";
import { currentStaff } from "@/lib/staffsession";
export const dynamic = "force-dynamic";
/* "Has anything changed?", answered in one integer.
*
* Every screen in the product already knows how to reload itself — a mutation ends in
* router.refresh(). What it could not know was that somebody ELSE had changed something, so a
* phone left open on a ward showed whatever the catalogue looked like when it was opened, and a
* coordinator adding a garment at the desk had to tell the counter to pull down to refresh.
*
* The obvious fix — poll the snapshot and diff it — is the expensive one: that is the facility's
* catalogue, staff register, stock and history, re-read on a timer by every open device to learn,
* almost always, that nothing happened. This returns the counter that the three mutating routes
* bump, so the cost of asking is a primary-key lookup, and the cost of the real reload is paid only
* when the number has actually moved.
*
* Both session kinds answer here. A coordinator at the desk and a wearer on a ward are watching the
* same facility, and there is nothing in a bare revision number to keep apart — it says that
* something changed, never what. Anyone with no session at all gets 401 rather than a number,
* because even "this facility is busy" is not ours to hand out.
*/
export async function GET(_req: NextRequest) {
const user = await currentUser();
const facilityId = user?.facilityId || (await currentStaff())?.facilityId;
if (!facilityId) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const f = await prisma.facility.findUnique({ where: { id: facilityId }, select: { rev: true } });
if (!f) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
// Never from a cache: a stale revision is indistinguishable from nothing having happened, which
// is the one wrong answer this endpoint can give.
return NextResponse.json({ rev: f.rev }, { headers: { "cache-control": "no-store" } });
}
+132
View File
@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { normaliseCode, setStaffCookie } from "@/lib/staffsession";
import { verifyTurnstile } from "@/lib/turnstile";
import { recordAuthEvent } from "@/lib/audit";
import { bumpRev } from "@/lib/ops";
import { SLIP_DAYS, facilityToday, slipLive } from "@/lib/compute";
export const dynamic = "force-dynamic";
const MIN_PW = 8;
/* Claiming your own record with the code the linen room printed for you.
*
* The code alone identifies the person, because it is presented before we know anything about them
* — there is no facility to scope it to and no email to look up yet. That is why it is globally
* unique, why it is 58 bits wide, and why this route is throttled to the point where working
* through the space is not a strategy.
*
* It is spent in the same update that finds it, so two people racing the same slip can't both
* claim the record; the loser gets the ordinary "code isn't right" message.
*
* It also goes stale on its own after fourteen days, because the far more likely way a slip is
* misused is not a guessed code but a printed one nobody ever collected.
*/
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const ip = clientIp(req.headers);
if (!allow("staff-activate:" + ip, 200, 15 * 60 * 1000)) {
return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 });
}
let body: { code?: unknown; email?: unknown; password?: unknown; cfToken?: unknown; agreed?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const code = normaliseCode(String(body.code ?? ""));
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160);
const password = String(body.password ?? "").slice(0, 200);
if (!code) return NextResponse.json({ error: "That code isn't right. It's twelve characters, in three groups." }, { status: 400 });
if (!/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) return NextResponse.json({ error: "Enter an email address you can get to." }, { status: 400 });
if (password.length < MIN_PW) return NextResponse.json({ error: `Use at least ${MIN_PW} characters for your password.` }, { status: 400 });
// The agreement is collected where the account is created. The screen's tick is what sets it,
// and the door checks it too so a client that skips the box gets the same answer.
if (body.agreed !== true) return NextResponse.json({ error: "Tick the box to agree to the terms of use and privacy policy." }, { status: 400 });
// Checked before the code is looked up, so a bot working through the code space is stopped by
// Cloudflare rather than by the per-IP throttle alone.
const cfErr = await verifyTurnstile(body.cfToken, ip);
if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
const staff = await prisma.staff.findUnique({
where: { activateCode: code },
select: { id: true, facilityId: true, first: true, last: true, inactive: true, activateCodeAt: true, account: { select: { id: true } }, facility: { select: { timezone: true } } },
});
// One message for every way this can fail, so the response can't be used to tell a real code from
// a spent one.
const nope = () => NextResponse.json({ error: "That code isn't right, or it has already been used. Ask the linen room for a new one." }, { status: 400 });
if (!staff || staff.inactive || staff.account) return nope();
/* An old slip is refused whether or not anyone ever claimed it. It is a bearer token on paper:
* whoever picks one out of a folder months later can bind their own email and password to this
* person's record and from then on be them — their issues, their requests, their signature on the
* ward round, and their approvals queue if they manage anyone. Fourteen days is long enough for
* someone on leave to come back to it and short enough that a forgotten one is dead by the time
* it turns up.
*
* An unstamped code counts as stale: its age is unknown, so it has to be assumed old. This leans
* on staff.selfCode in lib/ops.ts stamping activateCodeAt as it prints — if that stamp ever stops
* being written, every new slip is dead on arrival.
*
* This says plainly that the slip has expired rather than joining the deliberately vague message
* above. Landing here means the code was right, and a code is 58 bits behind a throttle and a
* Turnstile — so anyone who gets this far is holding a real slip and needs to be told that a
* reprint, not a retype, is the fix.
*
* The age is asked of slipLive() in lib/compute, the same test the staff register and the requests
* queue use to say whether a slip is still worth chasing, counted in whole days on the facility's
* own calendar. The day a coordinator's screen calls a slip expired is therefore the day this
* refuses it — never a few hours later, with a nurse who was told it was dead finding it still
* works, or one who was told it was fine being turned away. */
const tz = staff.facility.timezone;
if (!slipLive(staff.activateCodeAt, facilityToday(tz), tz)) {
return NextResponse.json(
{ error: `That code was printed ${SLIP_DAYS} or more days ago, so it has expired. Ask the linen room to print you a new slip.` },
{ status: 400 },
);
}
// The email has to be free across staff accounts. Coordinator accounts live in a different table
// and a person may legitimately be both — a linen-room supervisor who also wears the uniform.
const taken = await prisma.staffAccount.findUnique({ where: { email }, select: { id: true } });
if (taken) return NextResponse.json({ error: "That email is already on an account here. Sign in instead." }, { status: 400 });
const passwordHash = await bcrypt.hash(password, 12);
// Spend the code first, conditionally. If it has gone in the meantime, nothing was created.
// The stamp goes with the code, so a spent row can't be read as a slip still waiting out there.
const spent = await prisma.staff.updateMany({ where: { id: staff.id, activateCode: code }, data: { activateCode: null, activateCodeAt: null } });
if (spent.count !== 1) return nope();
let account;
try {
account = await prisma.staffAccount.create({
data: { facilityId: staff.facilityId, staffId: staff.id, email, passwordHash },
select: { id: true, passwordHash: true },
});
} catch {
// The code is gone but the account didn't happen — put the code back rather than stranding
// someone with a dead slip. The original print date goes back with it: a failed attempt is not
// a reprint and must not restart the fourteen days.
await prisma.staff.update({ where: { id: staff.id }, data: { activateCode: code, activateCodeAt: staff.activateCodeAt } }).catch(() => {});
return NextResponse.json({ error: "That didn't work — try again." }, { status: 500 });
}
await setStaffCookie(account.id, account.passwordHash);
recordAuthEvent(
{ facilityId: staff.facilityId, userId: staff.id, userName: `${staff.first} ${staff.last}`.trim() || email },
"staff:activate", ip,
);
// The fourth door that changes a facility's data, and the only one outside the three mutate
// routes. Without this the coordinator standing over the nurse while she activates keeps seeing
// "code outstanding" until some unrelated edit moves the revision, and reissues a code that
// can't be reissued.
await bumpRev(staff.facilityId);
return NextResponse.json({ ok: true, name: `${staff.first} ${staff.last}` });
}
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { readApprovalToken } from "@/lib/approvallink";
import { StaffOpError, decideRequest } from "@/lib/staffops";
import { recordFor } from "@/lib/audit";
import { bumpRev } from "@/lib/ops";
export const dynamic = "force-dynamic";
/* Deciding a request from the emailed link, without signing in.
*
* This is a POST and only a POST. The link in the email is a GET that renders /my/approve, and the
* decision is made from that page — because corporate mail scanners and link-preview crawlers
* fetch every URL in every message, and a GET that approved a uniform request would be approved by
* the mail gateway before the manager ever saw it.
*
* The decision itself is decideRequest()'s, not this route's. A request now carries a line per
* garment, and settling it means settling every line and then rolling the request up from them;
* an approval made here that moved only the request would leave every line `awaiting`, so the
* linen room's bag would come out empty and the wearer's order would show no decision at all.
* There is no room on this page for a garment-by-garment answer — there is no signed-in person to
* check one against — so it takes the whole-request shorthand, `approveAll`, which is the reason
* that argument exists.
*
* Single use falls out of the state machine rather than a table of spent tokens: decideRequest's
* update is conditional on the request still being `awaiting`, so the approve link and the decline
* link in the same email both stop working the moment either is used.
*/
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const ip = clientIp(req.headers);
if (!allow("staff-decide:" + ip, 200, 15 * 60 * 1000)) {
return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 });
}
let body: { token?: unknown; action?: unknown; reason?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const claim = readApprovalToken(String(body.token ?? ""));
if (!claim) return NextResponse.json({ error: "That link has expired. Open the app and use the approvals queue." }, { status: 400 });
const action = String(body.action ?? "");
if (action !== "approve" && action !== "decline") return NextResponse.json({ error: "Unknown action" }, { status: 400 });
let done: Awaited<ReturnType<typeof decideRequest>>;
try {
done = await decideRequest({
requestId: claim.rid,
managerId: claim.mid,
approveAll: action === "approve",
reason: body.reason,
});
} catch (e) {
if (!(e instanceof StaffOpError)) throw e;
/* The refusals are worded for somebody standing in their mail client, not in the app.
*
* A 403 here is decideRequest re-reading the register and finding the manager off it, or the
* wearer off it — the check that makes a fortnight-old token in a mailbox that has since been
* closed or handed on safe. Neither the sacked manager nor a stranger reading their mail is
* told which of the two it was; "ask the linen room" is where that conversation belongs.
*
* A 409 is the link already spent, and keeps the `already` flag the page reads to show the
* decision that was made rather than an error. */
if (e.status === 403) return NextResponse.json({ error: "That link is no longer valid — ask the linen room." }, { status: 403 });
if (e.status === 404) return NextResponse.json({ error: "That request is no longer there." }, { status: 404 });
if (e.status === 409) return NextResponse.json({ error: "That request has already been decided.", already: true }, { status: 409 });
return NextResponse.json({ error: e.message }, { status: e.status });
}
// Filed under the manager's own Staff id, exactly as the in-app approval is, so the log names
// the same person either way; the op says which door the decision came through, because "an
// email link, from an address we can't see" is part of the answer to who authorised this.
recordFor(
{ facilityId: done.facilityId, userId: claim.mid, userName: done.managerName },
done.status === "accepted" ? "staff:request.approve.email" : "staff:request.decline.email",
{ id: claim.rid }, ip,
);
// The third door into the facility's data, so the third place the revision has to move: a manager
// approving from their mail is exactly the change the linen room's screen is waiting to see.
await bumpRev(done.facilityId);
return NextResponse.json({ ok: true, status: done.status, notified: done.notified });
}
+45
View File
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { sameOriginJson } from "@/lib/csrf";
import { clientIp } from "@/lib/ratelimit";
import { signInStaff, staffThrottled } from "@/lib/staffauth";
import { verifyTurnstile } from "@/lib/turnstile";
export const dynamic = "force-dynamic";
/* The staff app's own door: the printed slip, the Play app's welcome, and /my/signin.
*
* The Log in box on the website reaches the same register through lib/staffauth.ts, so what counts
* as a match, what a deactivated record is told, and what lands in the audit trail are decided in
* one place for both. This route is the HTTP shape of it: the origin check, the security check, and
* the throttle asked in that order. */
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
let body: { email?: unknown; password?: unknown; cfToken?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160);
const password = String(body.password ?? "").slice(0, 200);
const ip = clientIp(req.headers);
// Asked before the security check, because a Turnstile token is good for one use and somebody who
// is already throttled should not spend theirs to be told so.
if (staffThrottled(email, ip)) {
return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 });
}
if (!email || !password) return NextResponse.json({ error: "Enter your email and password." }, { status: 400 });
// The same bot check the coordinator door has. A ward account opens one person's uniform record,
// and a manager's opens the approvals queue, so leaving this to the in-memory throttles alone
// meant a list of hospital addresses and enough patience was the whole attack.
const cfErr = await verifyTurnstile(body.cfToken, ip);
if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
// `true`: this is the register's own door, so an address with no account here is a plain wrong
// answer and is counted as one.
const r = await signInStaff(email, password, ip, true);
if (r.kind === "ok") return NextResponse.json({ ok: true, name: r.name });
if (r.kind === "error") return NextResponse.json({ error: r.error }, { status: r.status });
// Unreachable at this door: `none` is only returned when the caller asked not to be counted.
return NextResponse.json({ error: "Email or password doesnt match." }, { status: 401 });
}
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { sameOriginJson } from "@/lib/csrf";
import { clearStaffCookie, currentStaff } from "@/lib/staffsession";
import { clientIp } from "@/lib/ratelimit";
import { recordAuthEvent } from "@/lib/audit";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
// Read the session before dropping it, so the trail can say who left. Signing out still succeeds
// when there was nothing to sign out of.
const sess = await currentStaff();
await clearStaffCookie();
if (sess) {
recordAuthEvent(
{ facilityId: sess.facilityId, userId: sess.staffId, userName: `${sess.first} ${sess.last}`.trim() || sess.email },
"staff:signout", clientIp(req.headers),
);
}
return NextResponse.json({ ok: true });
}
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { currentStaff } from "@/lib/staffsession";
import { StaffOpError, runStaffOp } from "@/lib/staffops";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { recordStaffAudit } from "@/lib/audit";
import { bumpRev } from "@/lib/ops";
import { report } from "@/lib/glitchtip";
export const dynamic = "force-dynamic";
/* The one door for everything a wearer, manager or ward clerk changes.
*
* Separate from /api/mutate, and reached only with a staff session. The two never share a handler:
* a single endpoint that accepted either kind of caller would put the whole coordinator op table
* one authorisation slip away from a wearer's phone.
*/
export async function POST(req: NextRequest) {
const sess = await currentStaff();
if (!sess) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
let body: { op?: string; payload?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad JSON" }, { status: 400 }); }
const op = String(body.op || "");
if (!allow("staff-mutate:" + sess.accountId, 120, 60 * 1000)) {
return NextResponse.json({ error: "Slow down — too many changes in a minute." }, { status: 429 });
}
// Requests are the expensive ones: each sends an email to a manager. A tighter budget stops a
// stuck retry loop turning into a mailbox full of the same approval.
// damage.report and waitlist.accept raise a request (and mail the manager) through the same
// path, so they draw on the same budget — otherwise the loop just picks a different door.
if (["request.create", "damage.report", "waitlist.accept"].includes(op) && !allow("staff-request:" + sess.staffId, 12, 60 * 60 * 1000)) {
return NextResponse.json({ error: "That's a lot of requests in an hour — talk to the linen room." }, { status: 429 });
}
const ip = clientIp(req.headers);
try {
const payload = (body.payload || {}) as Record<string, unknown>;
const result = await runStaffOp(sess, op, payload);
// The same discipline as /api/mutate, and for the same reason: a uniform issued to a ward is
// authorised here as often as it is in the linen room, and "who approved this" is the question
// the trail exists to answer. Recorded only after the op actually succeeded.
recordStaffAudit(sess, op, payload, ip, result);
// Handed back so the screen that made this change does not bounce again when it next polls.
const rev = await bumpRev(sess.facilityId);
return NextResponse.json({ ok: true, result, rev });
} catch (e) {
if (e instanceof StaffOpError) return NextResponse.json({ error: e.message }, { status: e.status });
report({ error: e, where: "server", url: "/api/staff/mutate", tags: { op } });
console.error(`[staff mutate ${op}]`, e, "ip=", ip);
return NextResponse.json({ error: "Something went wrong — nothing was saved." }, { status: 500 });
}
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentStaff } from "@/lib/staffsession";
import { parseDataUrl, readPhoto } from "@/lib/photostore";
export const dynamic = "force-dynamic";
/* The signature on one of a staff member's own signed slips, for the staff app.
*
* Only the caller's own slip, and only one the counter sent to their app (toStaff). Any other id is
* simply not found, whoever's it is. Served with the same headers as /api/photo/[id]. */
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const sess = await currentStaff();
if (!sess) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const { id } = await ctx.params;
const slip = await prisma.slip.findFirst({
where: { id: String(id || "").slice(0, 40), staffId: sess.staffId, facilityId: sess.facilityId, toStaff: true },
select: { sigId: true, facilityId: true },
});
if (!slip?.sigId) return NextResponse.json({ error: "Not found" }, { status: 404 });
const ph = await prisma.photo.findFirst({
where: { id: slip.sigId, facilityId: slip.facilityId },
select: { data: true, path: true, mime: true },
});
if (!ph) return NextResponse.json({ error: "Not found" }, { status: 404 });
let mime = ph.mime;
let bytes: Buffer | null = null;
if (ph.path) {
bytes = await readPhoto(ph.path);
} else if (ph.data) {
const parsed = parseDataUrl(ph.data);
if (parsed) { mime = parsed.mime; bytes = parsed.bytes; }
}
if (!bytes) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!/^image\/(jpeg|png)$/.test(mime)) return NextResponse.json({ error: "Bad photo" }, { status: 500 });
return new NextResponse(new Uint8Array(bytes), {
headers: {
"content-type": mime,
"cache-control": "private, max-age=3600",
"content-disposition": "inline",
"x-content-type-options": "nosniff",
"content-security-policy": "sandbox",
},
});
}
+14
View File
@@ -0,0 +1,14 @@
import { redirect } from "next/navigation";
/* The activity log now lives in Settings Data & audit log. next.config.ts redirects this path as
* well; this stub keeps old links working if that map ever changes. Query strings carry over. */
export default async function Activity({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const sp = await searchParams;
const q = new URLSearchParams();
for (const [k, v] of Object.entries(sp)) {
if (k === "tab" || v === undefined) continue;
for (const one of Array.isArray(v) ? v : [v]) q.append(k, one);
}
q.set("tab", "audit");
redirect(`/app/settings?${q.toString()}`);
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { Suspense } from "react";
import Counter from "@/components/counter/Counter";
// useSearchParams (?staff=, ?mode=) needs a Suspense boundary for static rendering.
export default function CounterPage() {
return <Suspense fallback={null}><Counter /></Suspense>;
}
+28
View File
@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ManualShell from "@/components/ManualShell";
import { ManualArticle } from "@/components/ManualView";
import { findPage, neighbours } from "@/lib/manual";
/* One manual page inside the app: the same Markdown the website renders at /docs, framed by the
* app's own shell. The help mark on each screen links straight here. */
type Params = { params: Promise<{ section: string; slug: string }> };
export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { section, slug } = await params;
const p = findPage(section, slug);
return { title: p ? `${p.title} · Help` : "Help", robots: { index: false, follow: false } };
}
export default async function HelpPage({ params }: Params) {
const { section, slug } = await params;
const p = findPage(section, slug);
if (!p) notFound();
const { prev, next } = neighbours(p);
return (
<ManualShell base="/app/help" current={{ section, slug }} headings={p.headings}>
<ManualArticle page={p} base="/app/help" prev={prev} next={next} />
</ManualShell>
);
}
+26
View File
@@ -0,0 +1,26 @@
import ManualHome from "@/components/ManualHome";
import ManualShell from "@/components/ManualShell";
import FacilityRules from "@/components/FacilityRules";
export const metadata = { title: "Help", robots: { index: false, follow: false } };
/* Help inside the app: the manual's front page, with this facility's own rules above it. The rules
* panel reads the facility's settings, so a figure a coordinator has changed is the figure shown;
* the manual pages quote the defaults and say where each one is changed. */
export default function Help() {
return (
<ManualShell base="/app/help">
<ManualHome
base="/app/help"
title="Help"
lede="The ThreadCount manual, and this facility's own rules. Every screen also has a help mark beside its title that opens the page about that screen."
>
<section className="mn-section" id="rules">
<h2 className="mn-h2"><span className="n">00</span><span>This facility&rsquo;s rules</span></h2>
<p className="mn-p">Read from your settings, so these are the figures the counter applies today.</p>
<FacilityRules />
</section>
</ManualHome>
</ManualShell>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { redirect } from "next/navigation";
/* Issue Stock became the Counter. Old links, bookmarks and badge scans keep their query string. */
export default async function IssueRedirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
const sp = await searchParams;
const q = new URLSearchParams();
for (const [k, v] of Object.entries(sp)) {
if (Array.isArray(v)) v.forEach((x) => q.append(k, x));
else if (v !== undefined) q.set(k, v);
}
const qs = q.toString();
redirect(`/app/counter${qs ? `?${qs}` : ""}`);
}
+38
View File
@@ -0,0 +1,38 @@
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";
import SourceNotice from "@/components/SourceNotice";
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 />
{/* Community only: the AGPL's offer of source, to the people using this server. */}
<SourceNotice />
</SnapshotProvider>
);
}
+355
View File
@@ -0,0 +1,355 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { PageHead, Empty, Field, ItemSizePicker, LiveRegion } from "@/components/ui";
import { ReceiveDialog } from "@/components/dialogs";
import { Figures, MoreMenu, Panel, QtyStepper, Tag } from "@/components/portal";
import { Crumb, OrdersStyles } from "@/components/orders/bits";
import { viewPhoto } from "@/lib/photo";
import { key, supplierCodeOf, ccBudgetNote, ccFor, ccOfOrder, csvOf, daysBetween, fmtDate, isOverdue, label, money, orderTotal, staffName, statusTag, supplierInfo, csvEsc } from "@/lib/compute";
import { downloadCsv, esc, openPrintWindow } from "@/lib/print";
export default function OrderDetail() {
const { id } = useParams<{ id: string }>();
const { s, isAdmin, mutate } = useSnap();
const { byId, staffById } = useDerived();
const router = useRouter();
const o = s.orders.find((x) => x.id === id);
const [rcv, setRcv] = useState(false);
const [err, setErr] = useState("");
const [pick, setPick] = useState("");
const [priceDraft, setPriceDraft] = useState<Record<string, string>>({});
const [draft, setDraft] = useState<Record<string, string>>({});
// What the coordinator has tapped on the quantity steppers but the server hasn't confirmed yet.
// The state is what the screen shows; the ref is what the next tap adds to while it is set,
// because it is current the instant a tap happens, where the state and the snapshot are both a
// render (or a whole round trip) behind.
const [qtyDraft, setQtyDraft] = useState<Record<string, number>>({});
const qtyWanted = useRef<Record<string, number>>({});
// What the snapshot said about a line as its write came back, and the lines the latest snapshot
// has. Both are read by the backstop below, from a timer: a timer armed two renders ago still
// closes over that render's copy of the order, and judging the screen out of date from a copy
// that is itself out of date is exactly how the pre-tap quantity gets back under a finger.
const qtySeen = useRef<Record<string, number>>({});
const snapLines = useRef(o?.lines);
// The field edits a debounce is still sitting on, so leaving the page can send them (see below).
const fieldWanted = useRef<Record<string, string>>({});
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
/* Anything still in a debounce when this screen goes away is sent, not thrown away.
*
* Clearing the timers on unmount was silent data loss: tap + on a quantity, or type the invoice
* number, then click straight through to another page inside the debounce window and the change
* vanished — it was on the screen as the coordinator left, and the supplier got the old figure.
* The writes go out bare because the component is already gone: there is nothing left to show an
* error in, and the record is one refresh away for whoever opens it next. */
const flush = useRef<() => void>(() => {});
useEffect(() => {
flush.current = () => {
for (const [k, v] of Object.entries(fieldWanted.current)) void mutate("order.update", { id, [k]: v });
for (const [lineId, qty] of Object.entries(qtyWanted.current)) void mutate("order.lineQty", { id, lineId, qty });
};
});
useEffect(() => { const t = timers.current, f = flush; return () => { Object.values(t).forEach(clearTimeout); f.current(); }; }, []);
useEffect(() => { snapLines.current = o?.lines; });
// Hand a line back to the snapshot once the refreshed snapshot agrees with what was tapped (or
// the line is gone). Waiting for agreement rather than for the write to return matters: the
// provider re-renders on its own the moment a write lands, still carrying the old snapshot, and
// dropping the tapped number there would flick the counter back to the old quantity and again
// look like the taps had been lost.
useEffect(() => {
setQtyDraft((d) => {
const n = Object.fromEntries(Object.entries(d).filter(([k, v]) => {
const line = o?.lines.find((l) => l.id === k);
return qtyWanted.current[k] !== undefined || (!!line && line.qty !== v);
}));
return Object.keys(n).length === Object.keys(d).length ? d : n;
});
});
const [mailMsg, setMailMsg] = useState("");
if (!o) return <section><OrdersStyles /><PageHead title="Order not found" /><Crumb href="/app/orders/all" parent="Orders" current="Not found" /><Empty><Link href="/app/orders/all">All orders</Link></Empty></section>;
const st = o.staffId ? staffById[o.staffId] : undefined;
const overdue = isOverdue(o, s.today);
const forLabel = o.orderFor === "Stock" ? "For stock" : "For " + staffName(st, "staff member");
const ccCode = ccOfOrder(s, o, staffById);
const ccNote = ccBudgetNote(s, byId, staffById, ccCode, " (incl. this one)");
function saveField(k: string, v: string) {
setDraft((d) => ({ ...d, [k]: v }));
fieldWanted.current[k] = v;
clearTimeout(timers.current[k]);
timers.current[k] = setTimeout(async () => {
// Off the pending list the moment it is on its way: a keystroke that lands after this point
// has already put its own value back and scheduled its own timer.
if (fieldWanted.current[k] === v) delete fieldWanted.current[k];
const r = await mutate("order.update", { id: o!.id, [k]: v });
if (!r.ok) setErr(r.error);
}, 400);
}
const val = (k: keyof typeof o) => (draft[k] !== undefined ? draft[k] : String(o[k] ?? ""));
/* The order as the coordinator can actually see it: the taps and the typing still sitting in a
* debounce, laid over the snapshot that has not caught up with them yet.
*
* Everything that puts this order in front of a person reads it — the lines, the total, the
* printed purchase order, the CSV, the receive dialog — so what leaves the building says what the
* screen said when it was asked for. Printing from the snapshot sent the supplier a tunic count
* one tap behind. Sending the pending write first would not have fixed it: the refreshed snapshot
* lands some time after the write returns, and the print window has to open on the click itself
* or the browser blocks it. Actions the server answers out of its own copy go through flushQty()
* instead — that is what the database has to be right about. */
const onScreen = { ...o, ref: val("ref"), invoice: val("invoice"), tracking: val("tracking"), expected: val("expected"), supplier: val("supplier"), notes: val("notes"), lines: o.lines.map((l) => (qtyDraft[l.id] !== undefined ? { ...l, qty: qtyDraft[l.id] } : l)) };
async function act(op: string, payload: unknown) { setErr(""); const r = await mutate(op, payload); if (!r.ok) setErr(r.error); return r.ok; }
/* Steppers count from what has been tapped, never from the snapshot.
*
* order.lineQty takes an absolute quantity and the snapshot only catches up once a write comes
* back, so reading l.qty on every tap meant six quick taps on the size-14 tunic all posted
* qty: 2: the line settled at 2 or 3 and the purchase order went to the supplier four tunics
* short. Each tap now adds to the pending figure and the debounce sends whatever it reached.
*
* `shown` is the number on the screen, which is the one the coordinator is counting from. It
* matters in the gap between a write landing and the refreshed snapshot arriving: the pending
* figure is cleared the moment the write returns, so a tap in that gap would otherwise fall back
* to the snapshot and count from the old quantity again — the very defect this exists to stop.
* The pending figure still wins where it exists, because two taps in one frame both read the same
* already-rendered number. */
function bumpQty(lineId: string, shown: number, by: number) {
clearTimeout(timers.current["qtyclear:" + lineId]);
const next = (qtyWanted.current[lineId] ?? shown) + by;
qtyWanted.current[lineId] = next;
setQtyDraft((d) => ({ ...d, [lineId]: next }));
clearTimeout(timers.current["qty:" + lineId]);
timers.current["qty:" + lineId] = setTimeout(() => { void sendQty(lineId); }, 300);
}
async function sendQty(lineId: string) {
const want = qtyWanted.current[lineId];
if (want === undefined) return true;
clearTimeout(timers.current["qty:" + lineId]);
const ok = await act("order.lineQty", { id: o!.id, lineId, qty: want });
// A tap that landed while this write was in the air has already raised the target; leaving it
// pending lets the timer that tap scheduled send the higher number instead of losing it here.
if (qtyWanted.current[lineId] === want) {
delete qtyWanted.current[lineId];
// Nothing was saved, so the tapped number must come off the screen now rather than sit there
// above the error looking like a quantity the supplier is going to be sent.
if (!ok) dropPendingQty(lineId);
else {
qtySeen.current[lineId] = snapLines.current?.find((l) => l.id === lineId)?.qty ?? want;
timers.current["qtyclear:" + lineId] = setTimeout(() => dropOverriddenQty(lineId, want), 2000);
}
}
return ok;
}
/* The backstop for the case where the snapshot never comes to agree — someone else editing the
* same draft line. Without it this screen would keep showing our number over theirs.
*
* It runs on a clock, so it must never act on a snapshot that is merely late. Dropping the draft
* the moment the two seconds were up put the pre-tap quantity back on the screen whenever the
* refreshed snapshot was slower than that, and the next tap counted on from it — the miscount all
* of this exists to stop. A snapshot still showing the figure it had when our write came back,
* and not the figure we wrote, has not caught up yet: the tapped number stays and this waits
* another two seconds. Once it moves — to ours, or to whatever the other coordinator saved — the
* draft has nothing left to protect and goes. */
function dropOverriddenQty(lineId: string, wrote: number) {
const line = snapLines.current?.find((l) => l.id === lineId);
if (line && line.qty !== wrote && line.qty === qtySeen.current[lineId]) { timers.current["qtyclear:" + lineId] = setTimeout(() => dropOverriddenQty(lineId, wrote), 2000); return; }
dropPendingQty(lineId);
}
// Send anything still sitting in the debounce before an action the server answers out of its own
// copy of the order — it reads the lines the database holds, not the ones on this screen — or
// before one that closes the draft to edits and would have the pending write refused.
async function flushQty() {
for (const lineId of Object.keys(qtyWanted.current)) if (!(await sendQty(lineId))) return false;
return true;
}
function dropPendingQty(lineId: string) {
clearTimeout(timers.current["qty:" + lineId]);
clearTimeout(timers.current["qtyclear:" + lineId]);
delete qtyWanted.current[lineId];
delete qtySeen.current[lineId];
setQtyDraft((d) => { const n = { ...d }; delete n[lineId]; return n; });
}
async function removeLine(lineId: string) { dropPendingQty(lineId); await act("order.lineRemove", { id: o!.id, lineId }); }
/* Every line is priced the way orderTotal() prices it — delivered units at the cost the delivery
* was invoiced at, whatever is still outstanding at today's catalogue price — so the rows a
* coordinator ticks off against the invoice add up to the total printed under them. Pricing the
* rows from the catalogue while the total came from orderTotal() left the two visibly disagreeing
* as soon as a delivery arrived at a different price, on the one screen where that sum is checked.
*
* The amounts come out of orderTotal() itself rather than a second copy of its arithmetic: what
* line n contributes is the total of the first n lines less the total of the first n1. Asking it
* about a line on its own would not do, because it draws each delivery down across the lines in
* order — two lines for the same size would then both claim the same delivery. */
const lineAmt: Record<string, number> = {};
let runTotal = 0;
for (let i = 0; i < onScreen.lines.length; i++) { const t = orderTotal({ ...onScreen, lines: onScreen.lines.slice(0, i + 1) }, byId); lineAmt[onScreen.lines[i].id] = t - runTotal; runTotal = t; }
const unitOf = (l: { id: string; itemId: string; qty: number }) => (l.qty > 0 ? lineAmt[l.id] / l.qty : byId[l.itemId]?.cost || 0);
const received = (itemId: string, size: string) => o.receipts.reduce((t, r) => t + r.lines.filter((x) => x.itemId === itemId && x.size === size).reduce((a, x) => a + x.qty, 0), 0);
/* What the delivery docket gets checked against: the units this order asked for and the units
that have actually turned up. Both are read off the same lines the total is priced from, so the
figure above the table can never disagree with the table. */
const units = onScreen.lines.reduce((t, l) => t + l.qty, 0);
const got = o.receipts.reduce((t, rc) => t + rc.lines.reduce((n, l) => n + l.qty, 0), 0);
const total = orderTotal(onScreen, byId);
const ev: { date: string; what: string; sub: string; photoId?: string | null }[] = [{ date: o.date, what: "Order created", sub: o.replenish ? "Auto-built replenishment draft" : o.source }];
if (o.status !== "Draft" && o.status !== "Cancelled") ev.push({ date: o.date, what: "Placed with " + onScreen.supplier + (onScreen.ref ? " — ref " + onScreen.ref : ""), sub: "" });
for (const rc of o.receipts) ev.push({ photoId: rc.photoId, date: rc.date, what: "Delivery received" + (rc.invoice ? " — invoice " + rc.invoice : ""), sub: rc.lines.map((x) => `${label(byId[x.itemId])} ${x.size} ×${x.qty}${x.dest === "pickup" ? " → pickup" : " → shelf"}`).join(", ") + (rc.note ? " · " + rc.note : "") });
if (o.status === "Cancelled") ev.push({ date: "", what: "Order cancelled", sub: "" });
const backOrders = s.orders.filter((x) => x.parentId === o.id);
const parent = o.parentId ? s.orders.find((x) => x.id === o.parentId) : undefined;
function printPO() {
const sp = supplierInfo(s, onScreen.supplier);
const rows = onScreen.lines.map((l) => { const it = byId[l.itemId]; return `<tr><td>${esc(label(it))}</td><td>${esc(it?.sku || "—")}</td><td>${esc(l.size)}</td><td class="r">${l.qty}</td><td class="r">${esc(money(unitOf(l)))}</td><td class="r">${esc(money(lineAmt[l.id]))}</td></tr>`; }).join("");
const css = ".hd{display:flex;justify-content:space-between;align-items:flex-end;border-bottom:2px solid #201e1d;padding-bottom:8px}.hd h1{border:none;padding:0;font-size:20px}.meta2{display:grid;grid-template-columns:1fr 1fr;gap:4px 24px;margin:12px 0;font-size:12px;line-height:1.7}.tot{text-align:right;font-size:16px;font-weight:800;margin-top:10px}.notes{margin-top:14px;font-size:12px;color:#444}";
const body = `<div class="hd"><h1><span class="sq"></span>Purchase order — ${esc(onScreen.code)}</h1><div style="font-size:12px">${esc(s.settings.facility)} · ${esc(s.settings.location)}</div></div>` +
`<div class="meta2"><div>Supplier: <b>${esc(onScreen.supplier)}${sp && (sp.contact || sp.phone) ? " · " + esc([sp.contact, sp.phone].filter(Boolean).join(" · ")) : ""}</b></div><div>Date: <b>${esc(fmtDate(onScreen.date || s.today))}</b></div><div>Supplier ref: <b>${esc(onScreen.ref || "—")}</b></div><div>Expected: <b>${esc(onScreen.expected ? fmtDate(onScreen.expected) : "—")}</b></div><div>Account: <b>${esc(sp?.account || "—")}</b> · ${esc(forLabel)}</div><div>Cost centre: <b>${esc(ccCode || "—")}</b></div></div>` +
`<table><tr><th>Item</th><th>SKU</th><th>Size</th><th class="r">Qty</th><th class="r">Unit</th><th class="r">Total</th></tr>${rows}</table><div class="tot">Total ${esc(money(orderTotal(onScreen, byId)))}</div>` +
(onScreen.notes ? `<div class="notes">Notes: ${esc(onScreen.notes)}</div>` : "") + `<div class="notes">Ordered by ____________________ &nbsp;&nbsp; Date ____________</div>`;
openPrintWindow(onScreen.code, body, { page: "size:A4;margin:16mm", css, width: 780, height: 920 });
}
function exportCsv() {
downloadCsv((onScreen.code + (onScreen.ref ? "-" + onScreen.ref.replace(/[^A-Za-z0-9-]+/g, "_") : "")).toLowerCase() + ".csv", `Order,${csvEsc(onScreen.code)}\nSupplier,${csvEsc(onScreen.supplier)}\nRef,${csvEsc(onScreen.ref)}\n\n` + csvOf(["Item", "Supplier code", "SKU", "Size", "Qty", "Unit cost", "Total"], onScreen.lines.map((l) => { const it = byId[l.itemId]; return [label(it), supplierCodeOf(s, key(l.itemId, it ? it.sizes.map(String).indexOf(l.size) : -1)), it?.sku || "", l.size, l.qty, +unitOf(l).toFixed(2), lineAmt[l.id].toFixed(2)]; })));
}
async function emailSupplier() {
setMailMsg("Sending…");
const r = await mutate<{ sentTo: string }>("order.email", { id: o!.id });
setMailMsg(r.ok ? `Sent to ${r.result.sentTo}` : r.error);
}
async function duplicate() {
if (!(await flushQty())) return;
const r = await mutate<{ id: string }>("order.duplicate", { id: o!.id });
if (!r.ok) { setErr(r.error); return; }
router.push(`/app/orders/${r.result.id}`);
}
return (
<section>
<OrdersStyles />
<PageHead
title={o.code}
sub={<>{forLabel} · {onScreen.supplier} · placed {fmtDate(o.date)}{o.replenish ? " · replenishment" : ""}{parent && <> · back order of <Link href={`/app/orders/${parent.id}`} style={{ color: "inherit" }}>{parent.code}</Link></>}</>}
below={<div style={{ display: "flex", gap: 6, marginTop: 8, flexWrap: "wrap" }}>{overdue && <Tag tone="accent">Overdue</Tag>}<span className={statusTag(o.status)}>{o.status}</span></div>}
>
{o.status === "Draft" && isAdmin && <button type="button" className="btn btn-primary" onClick={async () => { if (await flushQty()) act("order.status", { id: o.id, status: "Ordered" }); }} disabled={o.lines.length === 0}>Mark ordered</button>}
{["Ordered", "Shipped", "Back Order"].includes(o.status) && <button type="button" className="btn btn-primary" onClick={async () => { if (await flushQty()) setRcv(true); }}>Receive delivery</button>}
{isAdmin && <a className="btn btn-onink" href={`/print/supplier-order?id=${o.id}`} target="_blank" rel="noreferrer" title="The A4 sheet with the supplier's product codes" onClick={() => { void mutate("order.printed", { id: o.id }); }}>Order sheet</a>}
<MoreMenu tone="ink" items={[
{ label: "Print order", onSelect: printPO },
{ label: "CSV", onSelect: exportCsv },
{ label: "Email supplier", onSelect: emailSupplier, hidden: !(isAdmin && o.status !== "Draft" && o.status !== "Cancelled") },
{ label: "Mark shipped", onSelect: () => { void act("order.status", { id: o.id, status: "Shipped" }); }, hidden: !(isAdmin && ["Ordered", "Back Order"].includes(o.status)) },
{ label: "Duplicate", onSelect: () => { void duplicate(); } },
{ label: "Cancel order", danger: true, hidden: !(isAdmin && ["Draft", "Ordered", "Back Order", "Shipped"].includes(o.status)), onSelect: async () => { if (confirm(`Cancel ${o.code}?`) && await flushQty()) act("order.status", { id: o.id, status: "Cancelled" }); } },
]} />
</PageHead>
<Crumb href="/app/orders/all" parent="Orders" current={o.code} />
<LiveRegion tone="alert" className="notice tc-flag" msg={err} style={{ marginBottom: 16, color: "var(--color-accent-700)", fontWeight: 700 }} />
<LiveRegion msg={mailMsg} className="notice" style={{ marginBottom: 16 }} />
<Figures items={[
{ value: money(total), label: "Order value", note: <span className="tc-mono">{onScreen.lines.length} line{onScreen.lines.length === 1 ? "" : "s"}</span> },
{ value: `${got} of ${units}`, label: "Units received" },
{ value: onScreen.expected ? fmtDate(onScreen.expected) : "—", label: "Expected", flag: overdue, note: overdue ? <span className="tc-mono">{daysBetween(onScreen.expected, s.today)} day{daysBetween(onScreen.expected, s.today) === 1 ? "" : "s"} overdue</span> : undefined },
]} />
<div className="tc-orders-detail">
<div style={{ display: "flex", flexDirection: "column", gap: 24, minWidth: 0 }}>
<Panel title="Order details" aside="Saves as you type">
<div className="tc-orders-fields">
{([["ref", "Supplier order no.", "text", "e.g. NW-48211"], ["invoice", "Invoice no.", "text", "e.g. INV-102938"], ["tracking", "Tracking no.", "text", "e.g. 34XY990812"], ["expected", "Expected delivery", "date", ""]] as const).map(([k, lbl, type, ph]) => (
<Field key={k} label={lbl}>{(c) => <input {...c} className="input" type={type} placeholder={ph} value={val(k)} onChange={(e) => saveField(k, e.target.value)} />}</Field>
))}
<Field label="Supplier">
{(c) => s.settings.suppliers.length ? <select {...c} className="input" value={val("supplier")} onChange={(e) => saveField("supplier", e.target.value)}>{[...new Set([o.supplier, ...s.settings.suppliers])].filter(Boolean).map((x) => <option key={x}>{x}</option>)}</select> : <input {...c} className="input" value={val("supplier")} onChange={(e) => saveField("supplier", e.target.value)} />}
</Field>
<Field label="Order for">
{(c) => (
<select {...c} className="input" value={o.staffId || ""} onChange={(e) => act("order.update", { id: o.id, staffId: e.target.value })}>
<option value="">Stock (linen room)</option>
{s.staff.filter((x) => !x.inactive || x.id === o.staffId).map((x) => <option key={x.id} value={x.id}>{x.first} {x.last} ({x.num})</option>)}
</select>
)}
</Field>
<Field label="Cost centre">
{(c) => (
<select {...c} className="input" value={val("cc") || (st ? st.dept : "")} onChange={(e) => act("order.update", { id: o.id, cc: e.target.value })}>
<option value=""> none </option>
{s.depts.map((d) => <option key={d.id} value={d.name}>{d.name}{d.cc ? ` (${d.cc})` : ""}</option>)}
{o.cc && !s.depts.find((d) => d.name === o.cc) && <option value={o.cc}>{o.cc}{ccFor(s, o.cc) ? "" : " (code)"}</option>}
</select>
)}
</Field>
{ccNote && <div style={{ gridColumn: "1 / -1", fontSize: 12, color: "var(--color-neutral-700)", borderLeft: "4px solid var(--color-text)", paddingLeft: "var(--space-2)" }}>{ccNote}</div>}
<Field label="Notes" style={{ gridColumn: "1 / -1" }}>{(c) => <input {...c} className="input" placeholder="e.g. rang the supplier re back order 12/8" value={val("notes")} onChange={(e) => saveField("notes", e.target.value)} />}</Field>
</div>
</Panel>
<div>
<Panel title="Lines" aside={<span className="tc-mono">{units} unit{units === 1 ? "" : "s"} ordered</span>}
foot={<><span className="tc-lbl">Total</span><span className="tc-mono" style={{ marginLeft: "auto", fontSize: 18, fontWeight: 600 }}>{money(total)}</span></>}>
<div>
{onScreen.lines.map((l) => {
const it = byId[l.itemId]; const rec = received(l.itemId, l.size); const cKey = l.id;
const catCost = it ? it.cost : 0;
// What this line is actually worth per unit once a delivery has been invoiced.
const unit = unitOf(l);
return (
<div key={l.id} className="tc-orders-row" style={{ fontSize: 13 }}>
<div className="tc-orders-rowmain" style={{ minWidth: 150 }}>
<div style={{ fontWeight: 600 }}>{label(it)} · <span className="tc-mono">{l.size}</span></div>
<div className="tc-orders-rowmeta">{supplierCodeOf(s, key(l.itemId, it ? it.sizes.map(String).indexOf(l.size) : -1)) && <span className="tc-mono">{supplierCodeOf(s, key(l.itemId, it ? it.sizes.map(String).indexOf(l.size) : -1))}</span>}{rec ? <> · received <span className="tc-mono">{rec}</span></> : ""}{Math.abs(unit - catCost) > 0.004 && <span title={`Delivered units are priced at what the invoice charged: ${money(unit)} a unit across this line`}> · invoice price</span>}</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)", flex: "none" }}>
{o.status === "Draft"
? <QtyStepper size="sm" min={1} value={l.qty} label={`${label(it)} size ${l.size}`} onChange={(n) => bumpQty(l.id, l.qty, n - l.qty)} />
: <span className="tc-mono" style={{ fontWeight: 600 }}>×{l.qty}</span>}
<span className="tc-mono">@ $</span>
<input className="input tc-mono" style={{ minHeight: 28, padding: "2px 8px", width: 70, textAlign: "right" }} inputMode="decimal" aria-label={`Unit cost of ${label(it)} size ${l.size}`} title={isAdmin ? "Editing a price updates that item's catalogue cost everywhere." : "Prices are set by an admin."} disabled={!isAdmin} value={priceDraft[cKey] !== undefined ? priceDraft[cKey] : String(catCost)}
onChange={(e) => setPriceDraft({ ...priceDraft, [cKey]: e.target.value.replace(/[^0-9.]/g, "") })}
onBlur={async () => { const v = parseFloat(priceDraft[cKey]); if (!isNaN(v) && v >= 0 && Math.abs(v - catCost) > 0.004 && it) { await act("catalog.update", { id: it.id, cost: v }); } const d = { ...priceDraft }; delete d[cKey]; setPriceDraft(d); }} />
{o.status === "Draft" && o.lines.length > 1 && <button type="button" className="btn btn-ghost" style={{ minHeight: 26, padding: "0 4px" }} aria-label={`Remove ${label(it)} size ${l.size} from this order`} title="Remove" onClick={() => removeLine(l.id)}>×</button>}
</div>
<div className="tc-mono" style={{ minWidth: 86, textAlign: "right", fontWeight: 500 }}>{money(l.qty * unit)}</div>
</div>
);
})}
</div>
{o.status === "Draft" && (
<div className="tc-orders-add">
<span className="tc-lbl" style={{ alignSelf: "center" }}>Add a line</span>
<ItemSizePicker s={s} itemId={pick} onItem={setPick} placeholder="Choose an item…" maxWidth={280} onSize={async (it, si) => { if (await flushQty()) act("order.lineAdd", { id: o.id, itemId: it.id, size: it.sizes[si], qty: 1 }); }} />
</div>
)}
</Panel>
{backOrders.length > 0 && <div className="tc-orders-rowmeta" style={{ marginTop: 8 }}>Back order{backOrders.length > 1 ? "s" : ""}: {backOrders.map((b) => <Link key={b.id} href={`/app/orders/${b.id}`} className="tc-mono" style={{ marginRight: 8 }}>{b.code}</Link>)}</div>}
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 24, minWidth: 0 }}>
<Panel title="History">
{ev.map((e, i) => (
<div key={i} className="tc-orders-row" style={{ alignItems: "flex-start", flexWrap: "nowrap" }}>
<div className="tc-mono" style={{ minWidth: 92, flex: "none", paddingTop: 1, fontSize: 12, color: "#57534f" }}>{e.date ? fmtDate(e.date) : "—"}</div>
<div className="tc-orders-rowmain">
<div style={{ fontWeight: 600 }}>{e.what}{e.photoId && <button type="button" className="btn btn-ghost" style={{ minHeight: 22, padding: "0 6px", marginLeft: 8 }} onClick={() => viewPhoto(e.photoId!)}>Invoice photo</button>}</div>
{e.sub && <div className="tc-orders-rowmeta">{e.sub}</div>}
</div>
</div>
))}
</Panel>
{st && (
<Panel title="Staff member">
<div style={{ fontSize: 13, lineHeight: 1.7, padding: "12px 16px" }}>
<div style={{ fontWeight: 600 }}><Link href={`/app/staff/${st.id}`} className="link-name">{staffName(st)}</Link> <span className="tc-mono" style={{ fontWeight: 400, color: "var(--color-neutral-700)" }}>{st.num}</span></div>
<div>{st.dept} · {st.phone || "no phone"}</div>
</div>
</Panel>
)}
</div>
</div>
{rcv && <ReceiveDialog order={onScreen} onClose={() => { setRcv(false); router.refresh(); }} />}
</section>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { Field, PageHead } from "@/components/ui";
import { Panel, Seg, SelectButton, Tag } from "@/components/portal";
import { ccOfOrder, csvOf, daysBetween, fmtDate, isOverdue, isPlacedOpen, label, money, orderTotal, staffName, statusTag } from "@/lib/compute";
import { downloadCsv } from "@/lib/print";
import { Crumb, NewOrder, OrdersStyles, shortDate } from "@/components/orders/bits";
const STATUSES = ["All", "Draft", "Open", "Received"] as const;
/* Every order: the filters, the status segment and the CSV that used to sit on the Ordering screen. */
export default function OrderLedgerPage() {
const { s } = useSnap();
const { byId, staffById } = useDerived();
const [dlg, setDlg] = useState(false);
const [q, setQ] = useState("");
const [sup, setSup] = useState("");
const [status, setStatus] = useState<(typeof STATUSES)[number]>("All");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [item, setItem] = useState("");
const supOpts = useMemo(() => [{ value: "", label: "All suppliers" }, ...[...new Set(s.orders.map((o) => o.supplier).filter(Boolean))].sort().map((x) => ({ value: x, label: x }))], [s.orders]);
const itemOpts = useMemo(() => {
const ids = new Set<string>();
for (const o of s.orders) for (const l of o.lines) ids.add(l.itemId);
return [{ value: "", label: "All garments" }, ...[...ids].map((id) => byId[id]).filter(Boolean).sort((a, b) => label(a).localeCompare(label(b))).map((it) => ({ value: it.id, label: label(it) }))];
}, [s.orders, byId]);
const rank = (o: (typeof s.orders)[number]) => (o.status === "Draft" ? 0 : isPlacedOpen(o) ? 1 : o.status === "Received" ? 2 : 3);
const ql = q.trim().toLowerCase();
const orders = s.orders.filter((o) => {
if (sup && o.supplier !== sup) return false;
if (status === "Draft" && o.status !== "Draft") return false;
if (status === "Open" && !isPlacedOpen(o)) return false;
if (status === "Received" && o.status !== "Received") return false;
if (from && o.date < from) return false;
if (to && o.date > to) return false;
if (item && !o.lines.some((l) => l.itemId === item)) return false;
if (ql) {
const st = o.staffId ? staffById[o.staffId] : undefined;
const hay = `${o.code} ${o.ref} ${o.invoice} ${o.tracking} ${o.supplier} ${staffName(st)} ${o.lines.map((l) => label(byId[l.itemId])).join(" ")}`.toLowerCase();
if (!hay.includes(ql)) return false;
}
return true;
}).sort((a, b) => rank(a) - rank(b) || (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
const narrowed = ql !== "" || sup !== "" || status !== "All" || from !== "" || to !== "" || item !== "";
/* The rows on screen, filters applied. Value is orderTotal() so the file agrees with the screen and
Reports; dates stay ISO so a spreadsheet sorts them; notes stay out. */
function exportCsv() {
const cols = ["Order no.", "Status", "Ordered", "Supplier", "Ordered for", "Staff member", "Supplier ref", "Invoice", "Tracking", "Cost centre", "Replenishment", "Expected", "Days overdue", "Received", "Lines", "Units ordered", "Units received", "Value"];
downloadCsv(`threadcount-orders-${s.today}.csv`, csvOf(cols, orders.map((o) => {
const st = o.staffId ? staffById[o.staffId] : undefined;
const units = o.lines.reduce((t, l) => t + l.qty, 0);
const got = o.receipts.reduce((t, rc) => t + rc.lines.reduce((n, l) => n + l.qty, 0), 0);
return [o.code, o.status, o.date, o.supplier, o.orderFor === "Stock" ? "Stock" : "Staff member", staffName(st), o.ref, o.invoice, o.tracking, ccOfOrder(s, o, staffById), o.replenish ? "Yes" : "No", o.expected, isOverdue(o, s.today) ? daysBetween(o.expected, s.today) : "", o.received, o.lines.length, units, got, +orderTotal(o, byId).toFixed(2)];
})));
}
return (
<section>
<OrdersStyles />
<PageHead title="All orders" sub={<span className="tc-mono">{orders.length} of {s.orders.length}</span>}>
<button type="button" className="btn btn-onink" onClick={exportCsv} disabled={orders.length === 0}>{narrowed ? `Export CSV (${orders.length} shown)` : "Export CSV"}</button>
<button type="button" className="btn btn-primary" onClick={() => setDlg(true)}>New order</button>
</PageHead>
<Crumb href="/app/orders" parent="Orders" current="All orders" />
<div className="tc-orders-filters">
<input className="input" style={{ width: 240 }} aria-label="Search orders by number, reference or invoice" placeholder="Order no., ref, invoice" value={q} onChange={(e) => setQ(e.target.value)} />
<SelectButton label="Supplier" value={sup} options={supOpts} onChange={setSup} anyValue="" />
<SelectButton label="Garment" value={item} options={itemOpts} onChange={setItem} anyValue="" />
<Field label="From">{(c) => <input {...c} className="input" style={{ width: 150 }} type="date" value={from} onChange={(e) => setFrom(e.target.value)} />}</Field>
<Field label="To">{(c) => <input {...c} className="input" style={{ width: 150 }} type="date" value={to} onChange={(e) => setTo(e.target.value)} />}</Field>
<Seg label="Status" opts={STATUSES} value={status} onChange={setStatus} />
</div>
<Panel title="Orders" aside={`${orders.length} shown`}>
{orders.length === 0 && <div className="tc-orders-row"><span className="tc-orders-rowmeta">{s.orders.length === 0 ? "No orders yet." : "No orders match."}</span></div>}
{orders.map((o) => {
const st = o.staffId ? staffById[o.staffId] : undefined;
const overdue = isOverdue(o, s.today);
const late = overdue ? daysBetween(o.expected, s.today) : 0;
const due = overdue
? late === 1 ? "due yesterday" : `${late} days overdue`
: isPlacedOpen(o) && o.expected ? (o.expected === s.today ? "due today" : `due ${shortDate(o.expected)}`) : "";
return (
<Link key={o.id} href={`/app/orders/${o.id}`} className={"tc-orders-row" + (overdue ? " urgent" : "")}>
<div className="tc-orders-rowmain" style={{ minWidth: 200 }}>
<div className="tc-orders-rowtitle">{o.code}</div>
<div className="tc-orders-rowmeta" title={fmtDate(o.date)}>
{o.orderFor === "Stock" ? "For stock" : "For " + staffName(st, "staff member")} · {o.supplier} · {shortDate(o.date)}{o.ref ? " · ref " + o.ref : ""}
{due && <> · <span style={{ color: "var(--color-accent-700)", fontWeight: 600 }}>{due}</span></>}
</div>
</div>
{o.replenish && <Tag>Replenishment</Tag>}
{overdue && <Tag tone="accent">Overdue</Tag>}
<span className={statusTag(o.status)}>{o.status}</span>
<span className="tc-mono" style={{ minWidth: 90, textAlign: "right", fontWeight: 500 }}>{money(orderTotal(o, byId))}</span>
<span aria-hidden="true" style={{ fontSize: 12, color: "#6c6764" }}></span>
</Link>
);
})}
</Panel>
{dlg && <NewOrder onClose={() => setDlg(false)} />}
</section>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { redirect } from "next/navigation";
/* The order list is now the To order column on /app/orders. next.config.ts redirects as well; this
* stub keeps an old bookmark working if that map ever changes. */
export default async function OrderListPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
const sp = await searchParams;
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(sp)) for (const x of Array.isArray(v) ? v : v === undefined ? [] : [v]) qs.append(k, x);
const q = qs.toString();
redirect(q ? `/app/orders?${q}` : "/app/orders");
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useState } from "react";
import { useSnap } from "@/lib/client";
import { PageHead } from "@/components/ui";
import ToOrder from "@/components/orders/ToOrder";
import OnTheWay from "@/components/orders/OnTheWay";
import ThisMonth from "@/components/orders/ThisMonth";
import RecentOrders from "@/components/orders/RecentOrders";
import { NewOrder, OrdersStyles } from "@/components/orders/bits";
export default function OrdersPage() {
const { isAdmin } = useSnap();
const [dlg, setDlg] = useState<null | "stock" | "staff">(null);
return (
<section>
<OrdersStyles />
<PageHead title="Orders">
<button type="button" className="btn btn-onink" onClick={() => setDlg("staff")}>Order for a person</button>
<button type="button" className="btn btn-primary" onClick={() => setDlg("stock")}>New order</button>
</PageHead>
<div className="tc-orders-grid">
<div className="tc-orders-toorder">{isAdmin ? <ToOrder /> : <RecentOrders />}</div>
<div className="tc-orders-otw"><OnTheWay /></div>
<div className="tc-orders-month"><ThisMonth /></div>
</div>
{dlg && <NewOrder onClose={() => setDlg(null)} initOrderFor={dlg === "staff" ? "Staff Member" : undefined} />}
</section>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import Link from "next/link";
import { PageHead, Empty } from "@/components/ui";
import { usePortalCounts } from "@/lib/portalcounts";
import { todayHeadLine } from "@/lib/today";
import SetupGroup from "@/components/today/SetupGroup";
import CollectGroup from "@/components/today/CollectGroup";
import RoundGroup from "@/components/today/RoundGroup";
import PickGroup from "@/components/today/PickGroup";
import ReceiveGroup from "@/components/today/ReceiveGroup";
import CountsGroup from "@/components/today/CountsGroup";
import RunsOutPanel from "@/components/today/RunsOutPanel";
import MonthEndPanel from "@/components/today/MonthEndPanel";
/* Today: the work queue. Each group is a thing somebody has to go and do, and a group with nothing
in it is not drawn. Membership and the head count both come from lib/portalcounts.ts, the same
numbers the rail badge shows. */
export default function TodayPage() {
const { today } = usePortalCounts();
const column: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 18, minWidth: 0 };
return (
<section>
<PageHead title="Today" sub={todayHeadLine(today.total, today.overdue)}>
<Link href="/app/counter" className="btn btn-primary">Open the counter</Link>
</PageHead>
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.75fr) minmax(0, 1fr)", gap: 24, alignItems: "start", marginTop: 24 }}>
<div style={column}>
<SetupGroup />
<CollectGroup />
<RoundGroup />
<PickGroup />
<ReceiveGroup />
<CountsGroup />
{today.total === 0 && <Empty pad={2}>Nothing in the queue.</Empty>}
</div>
<div style={column}>
<RunsOutPanel />
<MonthEndPanel />
</div>
</div>
</section>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useSnap } from "@/lib/client";
import { monthLabel } from "@/lib/compute";
import { PageHead } from "@/components/ui";
import { Icon, Seg } from "@/components/portal";
import { useReportData } from "@/components/reports/useReportData";
import MonthEndStrip from "@/components/reports/MonthEndStrip";
import SpendTab, { JOURNAL_ID } from "@/components/reports/SpendTab";
import StockTab from "@/components/reports/StockTab";
import PeopleTab from "@/components/reports/PeopleTab";
const TABS = ["spend", "stock", "people"] as const;
type Tab = (typeof TABS)[number];
const LABELS: Record<Tab, string> = { spend: "Spend", stock: "Stock", people: "People" };
/* The nine reports' old names, so a link to one of them still lands on the tab that holds it. */
const LEGACY: Record<string, Tab> = {
overview: "spend", journal: "spend",
valuation: "stock", shrinkage: "stock", "top-stock": "stock", topstock: "stock", suppliers: "stock",
exceptions: "people", approvals: "people", "pre-loved": "people", preloved: "people",
};
/* Screen-local layout. Scoped to .tc-rep so nothing leaks outside this screen. */
export default function ReportPage() {
return <Suspense fallback={null}><ReportInner /></Suspense>;
}
function ReportInner() {
const { s } = useSnap();
const router = useRouter();
const sp = useSearchParams();
const thisMonth = s.today.slice(0, 7);
const rawTab = (sp.get("tab") || "").toLowerCase();
const tab: Tab = (TABS as readonly string[]).includes(rawTab) ? (rawTab as Tab) : LEGACY[rawTab] ?? "spend";
const rawMonth = sp.get("month") || "";
const month = /^\d{4}-(0[1-9]|1[0-2])$/.test(rawMonth) ? rawMonth : thisMonth;
const d = useReportData(month);
const [jump, setJump] = useState(false);
function go(next: { tab?: Tab; month?: string }) {
const t = next.tab ?? tab, m = next.month ?? month;
const q = new URLSearchParams();
q.set("tab", t);
if (m !== thisMonth) q.set("month", m);
router.replace(`/app/report?${q.toString()}`, { scroll: false });
}
useEffect(() => {
if (!jump || tab !== "spend") return;
document.getElementById(JOURNAL_ID)?.scrollIntoView({ block: "start" });
setJump(false);
}, [jump, tab]);
const exportCsv = tab === "spend" ? d.csv.overview : tab === "stock" ? d.csv.valuation : d.csv.exceptions;
return (
<section className="tc-rep">
<PageHead title="Reports">
<span className="tc-selectbtn btn btn-onink" style={{ gap: 10 }}>
<span aria-hidden="true">{monthLabel(month)}</span>
<Icon name="chevronDown" size={16} />
<select aria-label="Reporting month" value={month} onChange={(e) => go({ month: e.target.value })}>
{d.R.repMonths.map((m) => <option key={m} value={m}>{monthLabel(m)}</option>)}
</select>
</span>
<button type="button" className="btn btn-onink" onClick={exportCsv}>Export CSV</button>
<button type="button" className="btn btn-primary" onClick={d.printEomPack}>Month-end pack</button>
</PageHead>
<div className="tc-rep-stack">
<MonthEndStrip month={month} onPrint={d.printEomPack} onJournal={() => { setJump(true); if (tab !== "spend") go({ tab: "spend" }); }} />
<div>
<Seg label="Report" opts={TABS} value={tab} labels={LABELS} onChange={(t) => go({ tab: t })} />
</div>
{tab === "spend" && <SpendTab d={d} onMonth={(m) => go({ month: m })} />}
{tab === "stock" && <StockTab d={d} />}
{tab === "people" && <PeopleTab d={d} />}
</div>
</section>
);
}
+142
View File
@@ -0,0 +1,142 @@
"use client";
/* The full request queue: /app/requests?filter=&staff=&ward=&open=
*
* Approval is the ward's and fulfilment is the linen room's; this screen is the linen room's side.
* The rows come from components/requests/RequestList, the same list the staff record and Today
* use. One payload is fetched and every filter, count and export is a narrowing of it. */
import { Suspense, useEffect, useMemo } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useSnap } from "@/lib/client";
import { Empty, ErrorLine, PageHead } from "@/components/ui";
import { MonoNum, Seg } from "@/components/portal";
import RequestList, {
REQUEST_FILTERS, REQUEST_FILTER_LABEL, RequestStyles, isRowFilter, requestCounts, requestsFor, scopePayload,
useRequestActions, useRequests, type RequestFilter,
} from "@/components/requests/RequestList";
import Queries from "@/components/requests/Queries";
import Damage from "@/components/requests/Damage";
import KitCheck from "@/components/requests/KitCheck";
import { exportCount, exportRequestsCsv } from "@/components/requests/csv";
// useSearchParams needs a Suspense boundary for static rendering.
export default function RequestsPage() {
return <Suspense fallback={null}><RequestsInner /></Suspense>;
}
const isFilter = (v: string | null): v is RequestFilter => !!v && (REQUEST_FILTERS as readonly string[]).includes(v);
function RequestsInner() {
const { s } = useSnap();
const router = useRouter();
const pathname = usePathname();
const sp = useSearchParams();
const rawFilter = sp.get("filter");
const staffId = sp.get("staff") || undefined;
const ward = sp.get("ward") || undefined;
const openId = sp.get("open");
const { data, error, reload } = useRequests();
const { act, error: actError } = useRequestActions(reload);
const person = staffId ? s.staff.find((x) => x.id === staffId) : undefined;
const scope = useMemo(() => ({ staffId, ward }), [staffId, ward]);
const scoped = useMemo(() => (data ? scopePayload(data, scope, person?.num) : null), [data, scope, person?.num]);
const counts = useMemo(() => (scoped ? requestCounts(scoped) : null), [scoped]);
/* A deep link to one request with no filter named lands on the first filter that holds it, so
?open= always shows the row expanded. */
const filter: RequestFilter = useMemo(() => {
if (isFilter(rawFilter)) return rawFilter;
if (openId && scoped) {
for (const f of ["todo", "open", "all"] as const) if (requestsFor(scoped.requests, f).some((r) => r.id === openId)) return f;
}
return "todo";
}, [rawFilter, openId, scoped]);
function setParams(next: Record<string, string | null>) {
const q = new URLSearchParams(sp.toString());
for (const [k, v] of Object.entries(next)) { if (v === null) q.delete(k); else q.set(k, v); }
const qs = q.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
}
// An unknown ?filter= is dropped rather than left in the address bar disagreeing with the screen.
useEffect(() => {
if (!rawFilter || isFilter(rawFilter)) return;
const q = new URLSearchParams(window.location.search);
q.delete("filter");
const qs = q.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
}, [rawFilter, router, pathname]);
const showing = staffId ? (person ? `${person.first} ${person.last}`.trim() : data?.requests.find((r) => r.staffId === staffId)?.staffName || "one person") : ward;
const more = data?.moreRequests ? "+" : "";
const segCounts = counts
? Object.fromEntries(REQUEST_FILTERS.map((f) => [f, `${counts[f]}${isRowFilter(f) ? more : ""}`])) as Record<RequestFilter, string>
: undefined;
const shown = scoped ? exportCount(filter, scoped) : 0;
const narrowed = !!showing || (filter !== "all" && filter !== "cycles");
return (
<section>
<RequestStyles />
<PageHead title="Requests">
<button type="button" className="btn btn-ghost" disabled={!scoped || shown === 0}
onClick={() => { if (scoped) exportRequestsCsv(s, filter, scoped, showing); }}>
{narrowed && filter !== "cycles" ? `Export CSV (${shown} shown)` : "Export CSV"}
</button>
</PageHead>
{showing && (
<div className="tc-req-actions" style={{ marginBottom: 12 }}>
<span className="tc-meta-line" style={{ fontSize: 13 }}>Showing <b style={{ color: "var(--color-text)" }}>{showing}</b></span>
<button type="button" className="btn btn-ghost" style={{ minHeight: 30, padding: "2px 8px" }}
aria-label={`Clear, show every ${staffId ? "person" : "ward"}`}
onClick={() => setParams(staffId ? { staff: null, open: null } : { ward: null, open: null })}>
Clear
</button>
</div>
)}
{!data ? (
error ? (
<>
<ErrorLine msg={error} />
<div style={{ marginTop: 12 }}><button type="button" className="btn btn-secondary" onClick={() => void reload()}>Try again</button></div>
</>
) : <Empty>Loading</Empty>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
{error && <ErrorLine msg={error} />}
{counts && counts.noapprover > 0 && filter !== "noapprover" && (
<div className="tc-flag tc-req-actions" style={{ border: "2px solid var(--color-text)", borderLeft: "4px solid var(--color-accent)", padding: "10px 16px", justifyContent: "space-between" }}>
<b style={{ fontSize: 13.5 }}>
<span className="tc-mark" aria-hidden="true" />
<MonoNum weight={600} tone="accent" size={15}>{counts.noapprover}{more}</MonoNum> with no approver
</b>
<button type="button" className="btn btn-secondary" onClick={() => setParams({ filter: "noapprover" })}>Address them</button>
</div>
)}
<div className="tc-req-scroll">
<Seg label="Which requests" opts={REQUEST_FILTERS} labels={REQUEST_FILTER_LABEL} counts={segCounts} value={filter}
onChange={(f) => setParams({ filter: f })} style={{ flexWrap: "nowrap", width: "max-content" }} />
</div>
{actError && <ErrorLine msg={actError} />}
{isRowFilter(filter) ? (
<RequestList filter={filter} openId={openId} data={scoped} reload={reload} />
) : filter === "queries" ? (
<Queries rows={scoped?.disputes ?? []} act={act} />
) : filter === "damage" ? (
<Damage rows={scoped?.damage ?? []} act={act} />
) : (
<KitCheck cycle={scoped?.cycle ?? null} shortfalls={scoped?.shortfalls ?? []} waiting={scoped?.waiting ?? []} act={act} />
)}
</div>
)}
</section>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { PageHead, Empty, LiveRegion } from "@/components/ui";
import { Panel, QueueRow, Seg } from "@/components/portal";
import { DeliverDialog } from "@/components/dialogs";
import type { PickupRec } from "@/lib/compute";
import { plural, roundSheet } from "@/lib/today";
import { Lines } from "@/components/today/Lines";
// Delivery rounds: every uncollected pickup by ward, handed over on the floor with an on-screen
// signature and a handover photo (DeliverDialog).
const ALL = "__all__";
export default function RoundsPage() {
const { s } = useSnap();
const { byId, staffById } = useDerived();
const [deliver, setDeliver] = useState<PickupRec | null>(null);
const [msg, setMsg] = useState("");
const [ward, setWard] = useState<string>(ALL);
useEffect(() => { const w = new URLSearchParams(window.location.search).get("ward"); if (w) setWard(w); }, []);
const sheet = useMemo(() => roundSheet(s, byId, staffById), [s, byId, staffById]);
const bags = sheet.reduce((t, w) => t + w.rows.length, 0);
const garments = sheet.reduce((t, w) => t + w.garments, 0);
const current = ward !== ALL && sheet.some((w) => w.ward === ward) ? ward : ALL;
const shown = current === ALL ? sheet : sheet.filter((w) => w.ward === current);
function choose(w: string) {
setWard(w);
const u = new URL(window.location.href);
if (w === ALL) u.searchParams.delete("ward"); else u.searchParams.set("ward", w);
window.history.replaceState(null, "", u.pathname + u.search);
}
const opts = [ALL, ...sheet.map((w) => w.ward)];
const labels: Record<string, string> = { [ALL]: "All" };
const counts: Record<string, number> = { [ALL]: bags };
for (const w of sheet) counts[w.ward] = w.rows.length;
return (
<section>
<PageHead title="Delivery rounds" sub={<span className="tc-mono">{plural(bags, "bag")} · {plural(sheet.length, "ward")} · {plural(garments, "garment")}</span>} />
<LiveRegion msg={msg} style={{ marginTop: 16, fontSize: 13, fontWeight: 600 }} />
{sheet.length > 1 && (
<div style={{ marginTop: 16, overflowX: "auto" }}>
<Seg label="Ward" opts={opts} value={current} onChange={choose} labels={labels} counts={counts} />
</div>
)}
{bags === 0 && <Empty>Nothing waiting for delivery.</Empty>}
<div style={{ display: "flex", flexDirection: "column", gap: 18, marginTop: 18 }}>
{shown.map((w) => (
<Panel key={w.ward} title={w.ward} aside={<span className="tc-mono">{w.cc || "—"} · {w.rows.length} to deliver</span>}>
{w.rows.map((r) => (
<QueueRow
key={r.p.id}
age={`${r.days}d`}
ageLabel="waiting"
urgent={r.late}
title={r.name}
titleMeta={r.phone ? (r.tel ? <a href={r.tel} style={{ color: "inherit" }}>{r.phone}</a> : r.phone) : undefined}
meta={<><Lines lines={r.lines} /> · {r.p.orderCode}</>}
actions={<button type="button" className="btn btn-primary" aria-label={`Sign for the delivery to ${r.name}`} onClick={() => setDeliver(r.p)}>Delivered sign</button>}
/>
))}
</Panel>
))}
</div>
{deliver && <DeliverDialog pickup={deliver} onClose={() => setDeliver(null)} onDone={(m) => setMsg(m)} />}
</section>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
/* Settings: seven sections in a side list, each a ?tab= of its own so deep links and the help mark
* land on the right one. Old tab names (general, account, locations, activity…) still resolve. */
import { Suspense, useEffect, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useSnap } from "@/lib/client";
import { PageHead } from "@/components/ui";
import dynamic from "next/dynamic";
import { CSV_TEMPLATES } from "@/lib/csv";
import { SECTIONS, SettingsStyles, resolveSection, type SectionId } from "@/components/settings/common";
import FacilitySection from "@/components/settings/FacilitySection";
import IssuingRules from "@/components/settings/IssuingRules";
import CatalogueSection from "@/components/settings/CatalogueSection";
import PlacesSection from "@/components/settings/PlacesSection";
import PeopleSignIn from "@/components/settings/PeopleSignIn";
import DataAudit from "@/components/settings/DataAudit";
// Plan pulls in Stripe; load it only when the Plan section is open.
const PlanTab = dynamic(() => import("@/components/PlanTab"), { ssr: false });
// useSearchParams needs a Suspense boundary for static rendering.
export default function SettingsPage() {
return <Suspense fallback={null}><SettingsInner /></Suspense>;
}
function SettingsInner() {
const { s, isAdmin } = useSnap();
const sp = useSearchParams();
const planShown = isAdmin && !!s.plan?.live && !s.demo;
// #hash forms from old links (/app/settings#account).
const [hash, setHash] = useState("");
useEffect(() => {
const read = () => setHash(window.location.hash.replace("#", ""));
read();
window.addEventListener("hashchange", read);
return () => window.removeEventListener("hashchange", read);
}, []);
const tabParam = sp.get("tab");
const importParam = sp.get("import") || "";
const importKind = CSV_TEMPLATES[importParam] ? importParam : undefined;
const resolved = resolveSection(tabParam || hash || (importKind ? "data" : ""));
let section: SectionId = resolved.section;
if (section === "plan" && !planShown) section = "facility";
const sections = SECTIONS.filter((x) => x.id !== "plan" || planShown);
return (
<section>
<SettingsStyles />
<PageHead title="Settings" />
<div className="tc-set">
<nav aria-label="Settings sections" className="tc-set-nav">
{sections.map((x) => (
<Link key={x.id} href={`/app/settings?tab=${x.id}`} scroll={false} aria-current={x.id === section ? "page" : undefined}>{x.label}</Link>
))}
</nav>
<div className="tc-set-body" key={section}>
{section === "facility" && <FacilitySection />}
{section === "issuing" && <IssuingRules />}
{section === "catalogue" && <CatalogueSection />}
{section === "places" && <PlacesSection />}
{section === "people" && <PeopleSignIn />}
{section === "data" && <DataAudit importKind={importKind} scrollAudit={resolved.audit} />}
{section === "plan" && planShown && <PlanTab />}
</div>
</div>
</section>
);
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { Suspense } from "react";
import StaffRecord from "@/components/people/Record";
// The record's tab and edit mode live in the address; useSearchParams needs a Suspense boundary.
export default function StaffProfile() {
return <Suspense fallback={null}><StaffRecord /></Suspense>;
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { Suspense } from "react";
import Register from "@/components/people/Register";
// Register reads its filters from the address; useSearchParams needs a Suspense boundary.
export default function StaffPage() {
return <Suspense fallback={null}><Register /></Suspense>;
}
+414
View File
@@ -0,0 +1,414 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Fragment, useEffect, useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { PageHead, Empty, ErrorLine, Field, Notice } from "@/components/ui";
import { AdjustDialog, DuplicateItemDialog, GROUPS_HINT, GroupsPicker, ScanVariantsDialog } from "@/components/dialogs";
import { Icon, MoreMenu, Panel, QtyStepper, Tag } from "@/components/portal";
import { StockStyles } from "@/components/stock/StockStyles";
import { wholeMoney } from "@/components/stock/url";
import { bcBound, countsAsIssued, fmtDate, forecastFor, forecastLabel, fyStart, garmentGroups, genderLabel, issueCost, itemOrderHistory, key, lastCountMap, locTree, money, onOrderMap, onhand, plOf, reorderAt, staffName, statusTag, supplierCodeOf, touched } from "@/lib/compute";
export default function GarmentPage() {
const { id } = useParams<{ id: string }>();
const { s, isAdmin, mutate } = useSnap();
const { L, byId, staffById } = useDerived();
const it = s.catalog.find((x) => x.id === id);
const [edit, setEdit] = useState(false);
const [f, setF] = useState({ item: "", sku: "", supplier: "", cost: "", gender: "Unisex", groups: [] as string[], notes: "" });
const [newSize, setNewSize] = useState("");
const [err, setErr] = useState("");
const [msg, setMsg] = useState("");
const [adjust, setAdjust] = useState<{ itemId: string; si: number } | null>(null);
const [scanSizes, setScanSizes] = useState(false);
const [dup, setDup] = useState(false);
// What is typed into each size's barcode box, until it is saved: a label code is as often read out
// and typed as it is scanned.
const [codes, setCodes] = useState<Record<number, string>>({});
const [rowErr, setRowErr] = useState<{ si: number; msg: string } | null>(null);
const [flash, setFlash] = useState<number | null>(null);
// Arriving from "Create and scan sizes" (?scan=1) opens the scanner straight away; ?size=<si>
// (a scanned garment, a size cell) brings that size's row into view and marks it for two seconds.
useEffect(() => {
const sp = new URLSearchParams(window.location.search);
if (sp.get("scan") === "1") {
if (isAdmin && it) setScanSizes(true);
sp.delete("scan");
window.history.replaceState(null, "", window.location.pathname + (sp.toString() ? "?" + sp.toString() : ""));
}
const size = sp.get("size");
if (size === null || !it) return;
const si = parseInt(size, 10);
if (!(si >= 0 && si < it.sizes.length)) return;
const t0 = window.setTimeout(() => {
const el = document.getElementById(`size-row-${si}`);
if (el) { el.scrollIntoView({ block: "center" }); el.focus({ preventScroll: true }); }
setFlash(si);
}, 50);
const t1 = window.setTimeout(() => setFlash(null), 2050);
return () => { window.clearTimeout(t0); window.clearTimeout(t1); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, !!it]);
const d = useMemo(() => {
if (!it) return null;
const oo = onOrderMap(s, byId).byKey;
const lastCount = lastCountMap(s);
const sizes = it.sizes.map((sz, si) => { const k = key(it.id, si); return { si, size: String(sz), key: k, oh: onhand(s, L, k), ro: reorderAt(s, k), touched: touched(s, L, k), barcode: bcBound(s, it, si), pl: plOf(s, k), onOrd: oo[k] || 0, last: lastCount[k] || "" }; });
const tot = sizes.reduce((t, v) => t + v.oh, 0);
const value = sizes.reduce((t, v) => t + Math.max(0, v.oh), 0) * it.cost;
const fy = fyStart(s.today);
const fyList = s.issues.filter((i) => i.itemId === it.id && i.date >= fy && countsAsIssued(i));
const fyIssued = fyList.reduce((t, i) => t + i.qty, 0);
const fySpend = fyList.reduce((t, i) => t + i.qty * issueCost(i, byId), 0);
const onOrder = sizes.reduce((t, v) => t + v.onOrd, 0);
const hist: { date: string; kind: string; cls: string; desc: string }[] = [];
for (const i of s.issues) if (i.itemId === it.id) {
hist.push({ date: i.date, kind: i.direct ? "Collected" : "Issued", cls: "tag tag-neutral", desc: `${it.sizes[i.si]} ×${i.qty}${staffName(staffById[i.staffId], "—")}` });
if (i.returned) hist.push({ date: i.returned.date, kind: i.returned.cond.replace("Returned - ", "Returned "), cls: "tag tag-outline", desc: `${it.sizes[i.si]} ×${i.qty}${staffName(staffById[i.staffId], "—")}` });
}
for (const o of s.orders) for (const rc of o.receipts) for (const l of rc.lines) if (l.itemId === it.id) hist.push({ date: rc.date, kind: "Received", cls: "tag tag-accent", desc: `${l.size} ×${l.qty}${o.code}${l.dest === "shelf" ? " → shelf" : " → staff pickup"}` });
// A counted correction isn't a write-off: it's the shelf disagreeing with the ledger, either way.
for (const m of s.moves) if (m.itemId === it.id) hist.push({ date: m.date, kind: m.reason === "Counted correction" ? "Counted" : m.qty < 0 ? "Write-off" : "Added", cls: "tag tag-outline", desc: `${it.sizes[m.si] ?? ""} ${m.reason === "Counted correction" ? (m.qty < 0 ? "" : "+") + Math.abs(m.qty) : "×" + Math.abs(m.qty)}${m.reason ? " — " + m.reason : ""}` });
hist.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
return { sizes, tot, value, fyIssued, fySpend, onOrder, hist: hist.slice(0, 25) };
}, [it, s, L, byId, staffById]);
if (!it || !d) {
return (
<section>
<StockStyles />
<PageHead title="Garment not found" />
<nav aria-label="Breadcrumb" className="tc-stk-crumb"><Icon name="chevronLeft" size={16} /><Link href="/app/stock">Stock</Link></nav>
<Empty>That garment isn&apos;t in the catalogue.</Empty>
</section>
);
}
const tagged = garmentGroups(it.groups);
const startEdit = () => { setF({ item: it.item, sku: it.sku, supplier: it.supplier, cost: String(it.cost), gender: it.gender, groups: tagged, notes: it.notes }); setErr(""); setEdit(true); };
const invalid = !f.item.trim() || !(parseFloat(f.cost) >= 0) || f.cost === "";
async function save() {
if (invalid) return;
const r = await mutate("catalog.update", { id: it!.id, item: f.item, sku: f.sku, supplier: f.supplier, cost: parseFloat(f.cost), gender: f.gender, groups: f.groups, notes: f.notes });
if (!r.ok) { setErr(r.error); return; }
setEdit(false);
}
async function act(op: string, payload: unknown) { setErr(""); setRowErr(null); setMsg(""); const r = await mutate(op, payload); if (!r.ok) setErr(r.error); }
const sizeInvalid = !newSize.trim() || it.sizes.map(String).includes(newSize.trim());
// Adding a size does not mint a barcode: most sizes arrive with the supplier's own number.
async function addSize() {
if (sizeInvalid) return;
setErr("");
const r = await mutate("catalog.update", { id: it!.id, addSize: newSize.trim() });
if (!r.ok) { setErr(r.error); return; }
setNewSize("");
}
// A size row's own refusal belongs on that row.
function rowFail(si: number, m: string) { setErr(""); setRowErr({ si, msg: m }); }
async function saveCode(si: number, size: string, bound: string, force = false) {
const code = (codes[si] ?? bound).trim();
if (code === bound) { setCodes((c) => ({ ...c, [si]: bound })); return; }
// Clearing the box is how a wrong code comes off.
if (!code) { if (confirm(`Unbind ${bound} from size ${size}?`)) await unbindCode(si, bound); return; }
setErr(""); setRowErr(null);
const r = await mutate("barcode.bind", { code, itemId: it!.id, si, force });
if (!r.ok) {
// "Already on another garment" is the one refusal force may clear.
if (!force && r.error.includes("re-bind to move it") && confirm(`${r.error}\n\nMove ${code} onto ${it!.item} · size ${size}?`)) { await saveCode(si, size, bound, true); return; }
rowFail(si, r.error); return;
}
setCodes((c) => ({ ...c, [si]: code }));
}
async function unbindCode(si: number, code: string) {
setErr(""); setRowErr(null);
const r = await mutate("barcode.unbind", { code });
if (!r.ok) { rowFail(si, r.error); return; }
setCodes((c) => ({ ...c, [si]: "" }));
}
// Our own number for sizes that arrived without one. Only fills gaps; the server has the last word.
async function generateAll() {
const missing = d!.sizes.filter((v) => !v.barcode).length;
if (missing && !confirm(`Generate a barcode for the ${missing} size${missing === 1 ? "" : "s"} on ${it!.item} with none? Sizes with a suppliers code keep it.`)) return;
setErr(""); setRowErr(null); setMsg("");
const r = await mutate<{ made: { si: number; size: string; code: string }[]; count: number }>("barcode.generate", { itemId: it!.id });
if (!r.ok) { setErr(r.error); return; }
setCodes({});
setMsg(`Generated ${r.result.count} barcode${r.result.count === 1 ? "" : "s"} — size${r.result.count === 1 ? "" : "s"} ${r.result.made.map((m) => m.size).join(", ")}.`);
}
async function generateOne(si: number, size: string) {
setErr(""); setRowErr(null); setMsg("");
const r = await mutate<{ made: { si: number; size: string; code: string }[]; count: number }>("barcode.generate", { itemId: it!.id, si });
if (!r.ok) { rowFail(si, r.error); return; }
setCodes((c) => { const n = { ...c }; delete n[si]; return n; });
setMsg(`Size ${size} now carries ${r.result.made.map((m) => m.code).join(", ")}.`);
}
async function removeSize(si: number, size: string) {
if (!confirm(`Remove size ${size} from ${it!.item}? Its reorder level and barcode go with it.`)) return;
setErr(""); setRowErr(null);
const r = await mutate("catalog.removeSize", { id: it!.id, si });
if (!r.ok) { rowFail(si, r.error); return; }
// Every size above the removed one shifts down a place.
setCodes({});
}
// One label per garment on hand across the sizes that carry a code, so the count goes on the menu
// item and into the question before the print dialog opens.
const labelled = d.sizes.filter((v) => v.barcode).length;
const labels = d.sizes.reduce((t, v) => t + (v.barcode ? Math.max(0, v.oh) : 0), 0);
function printLabels() {
if (labels && !confirm(`Print ${labels} label${labels === 1 ? "" : "s"} for ${it!.item}? One for every garment on hand, across the ${labelled} size${labelled === 1 ? "" : "s"} carrying a barcode.`)) return;
window.open(`/print/labels?item=${encodeURIComponent(it!.id)}`, "_blank", "noopener");
}
const locOpts = locTree(s).map(({ loc, depth }) => ({ id: loc.id, name: " ".repeat(depth * 2) + loc.name }));
const sp = s.supplierDir.find((x) => x.name === it.supplier);
const hist = itemOrderHistory(s, it.id);
const prices = s.costs.filter((c) => c.itemId === it.id).sort((a, b) => b.at.localeCompare(a.at));
const colCount = 10;
return (
<section>
<StockStyles />
<PageHead
title={it.item}
below={
<div className="tc-stk-tags">
{tagged.length ? tagged.map((g) => <Tag key={g}>{g}</Tag>) : <Tag>All groups</Tag>}
{it.gender !== "Unisex" && <Tag>{genderLabel(it.gender)}</Tag>}
<Tag>{it.supplier || "No supplier"}</Tag>
{it.archived && <Tag tone="accent">Discontinued</Tag>}
</div>
}
>
<div className="tc-stk-headfig">
<span className="tc-stk-mono">{d.tot}</span>
on hand · <span className="tc-mono">{wholeMoney(d.value)}</span>
</div>
{isAdmin && (!edit ? (
<>
<button type="button" className="btn btn-primary" onClick={startEdit}>Edit garment</button>
<button type="button" className="btn btn-onink" onClick={() => setScanSizes(true)}>Scan sizes</button>
<MoreMenu tone="ink" items={[
{ label: `Print labels (${labels})`, onSelect: printLabels },
{ label: "Generate barcodes", onSelect: generateAll },
{ label: "Duplicate", onSelect: () => setDup(true) },
it.archived
? { label: "Reinstate", onSelect: () => act("catalog.update", { id: it.id, archived: false }) }
: { label: "Discontinue", danger: true, onSelect: () => act("catalog.update", { id: it.id, archived: true }) },
]} />
</>
) : (
<>
<button type="button" className="btn btn-primary" onClick={save} disabled={invalid}>Save changes</button>
<button type="button" className="btn btn-onink" onClick={() => setEdit(false)}>Cancel</button>
</>
))}
</PageHead>
<nav aria-label="Breadcrumb" className="tc-stk-crumb">
<Icon name="chevronLeft" size={16} /><Link href="/app/stock">Stock</Link><span aria-hidden="true">/</span><span style={{ fontWeight: 600, color: "var(--color-text)" }} aria-current="page">{it.item}</span>
</nav>
<ErrorLine msg={err} />
<Notice msg={msg} />
<div className="tc-stk-grid" style={{ marginTop: 8 }}>
<div className="tc-stk-col">
{edit && (
<Panel title="Edit details">
<div className="tc-stk-pad tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<Field label="Item name" style={{ gridColumn: "1 / -1" }} error={!f.item.trim() ? "Needed — the garment has to have a name." : undefined}>{(c) => <input {...c} className="input" value={f.item} onChange={(e) => setF({ ...f, item: e.target.value })} />}</Field>
<Field label="SKU / style code">{(c) => <input {...c} className="input" value={f.sku} onChange={(e) => setF({ ...f, sku: e.target.value })} />}</Field>
<Field label="Unit cost ($)" error={f.cost !== "" && !(parseFloat(f.cost) >= 0) ? "Give a number, or 0." : undefined}>{(c) => <input {...c} className="input" inputMode="decimal" value={f.cost} onChange={(e) => setF({ ...f, cost: e.target.value.replace(/[^0-9.]/g, "") })} />}</Field>
<Field label="Supplier">{(c) => <><input {...c} className="input" list="tc-suppliers" value={f.supplier} onChange={(e) => setF({ ...f, supplier: e.target.value })} /><datalist id="tc-suppliers">{s.settings.suppliers.map((x) => <option key={x} value={x} />)}</datalist></>}</Field>
<Field label="Gender">{(c) => <select {...c} className="input" value={f.gender} onChange={(e) => setF({ ...f, gender: e.target.value })}><option value="Unisex">Unisex</option><option value="Male">Men&apos;s</option><option value="Female">Women&apos;s</option></select>}</Field>
<GroupsPicker style={{ gridColumn: "1 / -1" }} value={f.groups} onChange={(groups) => setF({ ...f, groups })} groups={s.settings.staffGroups} hint={GROUPS_HINT} />
<Field label="Notes" style={{ gridColumn: "1 / -1" }}>{(c) => <textarea {...c} className="input" rows={2} value={f.notes} onChange={(e) => setF({ ...f, notes: e.target.value })} placeholder="Fit notes, replacement style, supplier quirks…" />}</Field>
</div>
</Panel>
)}
<Panel title="Sizes" aside={<span className="tc-mono">{labelled} of {it.sizes.length} carry a barcode</span>}
foot={isAdmin ? (
<div className="tc-stk-row" style={{ alignItems: "flex-end", width: "100%" }}>
<Field label="Add a size" hint="Starts with no barcode." style={{ flex: 1, minWidth: 160 }} error={newSize.trim() && it.sizes.map(String).includes(newSize.trim()) ? "That size is already on this garment." : undefined}>{(c) => <input {...c} className="input" value={newSize} onChange={(e) => setNewSize(e.target.value)} placeholder="e.g. 6XL or 127" onKeyDown={(e) => { if (e.key === "Enter") addSize(); }} />}</Field>
<button type="button" className="btn btn-secondary" onClick={addSize} disabled={sizeInvalid}>Add size</button>
</div>
) : undefined}>
<div className="table-wrap">
<table className="tc-table tc-stk-table" style={{ minWidth: isAdmin ? 1040 : 860 }}>
<thead>
<tr>
<th>Size</th><th>Barcode</th><th>Status</th><th className="num">On hand</th><th className="num">Pre-loved</th><th className="num">On order</th><th>Last counted</th><th>Location</th><th className="num">Reorder at</th><th><span className="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
{d.sizes.map((v) => {
const status = v.oh <= 0 ? (v.touched ? "out" : "none") : v.oh <= v.ro ? "reorder" : "ok";
const draft = codes[v.si] ?? v.barcode;
const dirty = draft.trim() !== v.barcode;
return (
<Fragment key={v.si}>
<tr id={`size-row-${v.si}`} tabIndex={-1} className={flash === v.si ? "tc-stk-flash" : undefined}>
<td className="tc-mono" style={{ fontWeight: 600 }}>{v.size}</td>
<td>
{isAdmin ? (
<span className="tc-stk-row" style={{ gap: 4, flexWrap: "nowrap" }}>
<input className="input tc-stk-tight tc-mono" style={{ width: 150 }} value={draft} maxLength={64} inputMode="numeric" placeholder="Not bound"
aria-label={`Barcode for size ${v.size}`} title="Type or scan the label code. Clear the box to unbind."
onChange={(e) => setCodes({ ...codes, [v.si]: e.target.value })}
onKeyDown={(e) => { if (e.key === "Enter") saveCode(v.si, v.size, v.barcode); }} />
{dirty
? <button type="button" className="btn btn-secondary tc-stk-tight" aria-label={`Save the barcode for size ${v.size}`} onClick={() => saveCode(v.si, v.size, v.barcode)}>Save</button>
: v.barcode
? <button type="button" className="btn btn-ghost btn-icon" title="Unbind this barcode" aria-label={`Unbind barcode ${v.barcode} from size ${v.size}`} onClick={() => { if (confirm(`Unbind ${v.barcode} from size ${v.size}?`)) unbindCode(v.si, v.barcode); }}>×</button>
: <button type="button" className="btn btn-ghost tc-stk-tight" aria-label={`Generate a barcode for size ${v.size}`} onClick={() => generateOne(v.si, v.size)}>Generate</button>}
</span>
) : (
<span className="tc-mono" style={{ fontSize: 12, color: v.barcode ? undefined : "var(--color-neutral-600)" }}>{v.barcode || "Not bound"}</span>
)}
</td>
<td>{status === "out" ? <Tag tone="accent">Out</Tag> : status === "reorder" ? <Tag tone="low">Reorder</Tag> : status === "ok" ? <Tag tone="quiet">OK</Tag> : <Tag tone="quiet"></Tag>}</td>
<td className="num" style={{ fontWeight: 600, color: status === "out" || status === "reorder" ? "var(--color-accent-700)" : undefined }}>{v.oh}</td>
<td className="num">{v.pl > 0 ? v.pl : ""}</td>
<td className="num">{v.onOrd > 0 ? v.onOrd : ""}</td>
<td className="tc-mono" style={{ fontSize: 12 }}>{v.last ? fmtDate(v.last) : "never"}</td>
<td>
<select className="input tc-stk-tight" value={s.placed[v.key] || ""} aria-label={`Where size ${v.size} lives`}
onChange={(e) => act("location.place", { itemId: it.id, si: v.si, locationId: e.target.value })}
disabled={s.locations.length === 0} title={s.locations.length === 0 ? "No locations yet" : undefined}>
<option value="">{s.locations.length === 0 ? "—" : "Unplaced"}</option>
{locOpts.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
</td>
<td className="num">
{isAdmin
? <QtyStepper size="sm" label={`reorder level for size ${v.size}`} value={v.ro} onChange={(n) => act("stock.reorder", { itemId: it.id, si: v.si, reorder: Math.max(0, n) })} />
: v.ro}
</td>
<td style={{ whiteSpace: "nowrap", textAlign: "right" }}>
<button type="button" className="btn btn-ghost tc-stk-tight" aria-label={`Adjust the quantity of size ${v.size}`} onClick={() => setAdjust({ itemId: it.id, si: v.si })}>Adjust</button>
{isAdmin && <> <button type="button" className="btn btn-ghost tc-stk-tight" aria-label={`Remove size ${v.size} from this garment`} onClick={() => removeSize(v.si, v.size)}>Remove</button></>}
</td>
</tr>
{rowErr?.si === v.si && <tr><td colSpan={colCount} style={{ borderTop: 0, paddingTop: 0 }}><ErrorLine msg={rowErr.msg} /></td></tr>}
</Fragment>
);
})}
</tbody>
</table>
</div>
</Panel>
<Panel title="Ordering" aside={<>{it.supplier || "no supplier"}{sp?.lead ? <> · <span className="tc-mono">{sp.lead}</span>-day lead</> : null}</>}>
<div className="table-wrap">
<table className="tc-table" style={{ minWidth: 620 }}>
<thead>
<tr><th>Size</th><th>Supplier code</th><th title="Weekly issues over 13 weeks (26 if none) × (lead time + 2 weeks)">Usage · suggested reorder</th><th className="num">Reorder at</th></tr>
</thead>
<tbody>
{d.sizes.map((v) => {
const fc = forecastFor(s, L, byId, v.key);
const code = supplierCodeOf(s, v.key);
return (
<tr key={"ord" + v.si}>
<td className="tc-mono" style={{ fontWeight: 600 }}>{v.size}</td>
<td>{isAdmin
? <input key={code} className="input tc-stk-tight tc-mono" style={{ width: 180 }} defaultValue={code} placeholder="e.g. NW-10422-M" maxLength={60} aria-label={`Supplier code for size ${v.size}`}
onBlur={(e) => { if (e.target.value.trim() !== code) act("stock.supplierCode", { itemId: it.id, si: v.si, code: e.target.value.trim() }); }}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} />
: <span className="tc-mono">{code || "—"}</span>}</td>
<td>
<span className="tc-stk-row" style={{ gap: 8 }}>
<span>{fc.suggestedReorder !== null ? <><b>Suggested <span className="tc-mono">{fc.suggestedReorder}</span></b> · {forecastLabel(fc)}</> : forecastLabel(fc)}</span>
{fc.runsOutBeforeDelivery && <Tag tone="low" title="At the current rate the shelf runs out before a delivery placed today would arrive">runs out before delivery</Tag>}
{isAdmin && fc.suggestedReorder !== null && fc.suggestedReorder !== v.ro && <button type="button" className="btn btn-ghost tc-stk-tight" aria-label={`Set the reorder level for size ${v.size} to ${fc.suggestedReorder}`} onClick={() => act("stock.reorder", { itemId: it.id, si: v.si, reorder: fc.suggestedReorder })}>Use</button>}
</span>
</td>
<td className="num" style={{ fontWeight: 600 }}>{v.ro}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Panel>
<Panel title="Orders" aside={<span className="tc-mono">{hist.length} line{hist.length === 1 ? "" : "s"}</span>}>
{hist.length === 0 ? <div className="tc-stk-pad"><Empty pad={2}>Never ordered.</Empty></div> : (
<div className="table-wrap">
<table className="tc-table" style={{ minWidth: 820 }}>
<thead>
<tr><th>Date</th><th>Order</th><th>Supplier</th><th>Size</th><th className="num">Qty</th><th className="num">Unit then</th><th>Supplier ref</th><th>Invoice</th><th>Status</th></tr>
</thead>
<tbody>
{hist.map((h, i) => (
<tr key={i}>
<td className="tc-mono">{fmtDate(h.date)}</td>
<td className="tc-mono"><Link href={`/app/orders/${h.orderId}`}>{h.code}</Link></td>
<td>{h.supplier || "—"}</td>
<td className="tc-mono">{h.size}</td>
<td className="num" style={{ fontWeight: 600 }}>{h.qty}</td>
<td className="num">{h.unit ? money(h.unit) : "—"}</td>
<td className="tc-mono">{h.ref || "—"}</td>
<td className="tc-mono">{h.invoice || "—"}</td>
<td><span className={statusTag(h.status)}>{h.status}</span></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Panel>
<Panel title="Price history" aside={<>now <span className="tc-mono">{money(it.cost)}</span></>}>
{prices.length === 0 ? <div className="tc-stk-pad"><Empty pad={2}>No price changes recorded.</Empty></div> : (
<div>
{prices.map((c) => (
<div key={c.id} className="tc-stk-kv">
<span><span className="tc-mono">{fmtDate(c.at.slice(0, 10))}</span>{c.byName ? ` · ${c.byName}` : ""}</span>
<span className="tc-mono">{c.previous !== null ? `${money(c.previous)}` : ""}{money(c.cost)}</span>
</div>
))}
</div>
)}
</Panel>
{it.notes && !edit && (
<Panel title="Notes">
<div className="tc-stk-pad" style={{ fontSize: 13, lineHeight: 1.6, whiteSpace: "pre-wrap" }}>{it.notes}</div>
</Panel>
)}
</div>
<div className="tc-stk-col">
<Panel title="This financial year">
<div>
{([["Issued this FY", `${d.fyIssued}`], ["FY spend (at issue price)", money(d.fySpend)], ["On open orders", `${d.onOrder}`], ["Unit cost", money(it.cost)], ["Sizes carried", String(it.sizes.length)]] as const).map(([k, v]) => (
<div key={k} className="tc-stk-kv"><span>{k}</span><span className="tc-mono" style={{ fontWeight: 600 }}>{v}</span></div>
))}
</div>
</Panel>
<Panel title="Recent movement" aside={d.hist.length > 0 ? "newest first" : undefined}>
{d.hist.length === 0 ? <div className="tc-stk-pad"><Empty pad={2}>No movement recorded yet.</Empty></div> : (
<div>
{d.hist.map((h, i) => (
<div key={i} className="tc-stk-kv" style={{ justifyContent: "flex-start" }}>
<span className="tc-mono tc-stk-meta" style={{ flex: "none", width: 84 }}>{fmtDate(h.date)}</span>
<span className={h.cls} style={{ flex: "none" }}>{h.kind}</span>
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{h.desc}</span>
</div>
))}
</div>
)}
</Panel>
</div>
</div>
{adjust && <AdjustDialog init={adjust} onClose={() => setAdjust(null)} />}
{/* Drafts go when the scanner closes: it binds codes to these same sizes. */}
{scanSizes && <ScanVariantsDialog item={it} onClose={() => { setScanSizes(false); setCodes({}); }} />}
{dup && <DuplicateItemDialog item={it} onClose={() => setDup(false)} />}
</section>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useSnap } from "@/lib/client";
import { PageHead } from "@/components/ui";
import { Seg } from "@/components/portal";
import { ItemDialog, ScanAddDialog } from "@/components/dialogs";
import OnHand, { asStockFilter } from "@/components/stock/OnHand";
import CountTab from "@/components/stock/CountTab";
import Locations from "@/components/stock/Locations";
import { StockStyles } from "@/components/stock/StockStyles";
import { ALL_GROUPS } from "@/lib/compute";
const TABS = ["onhand", "count", "locations"] as const;
type StockTab = (typeof TABS)[number];
const TAB_LABELS: Record<StockTab, string> = { onhand: "On hand", count: "Count", locations: "Locations" };
const TAB_HREFS: Record<StockTab, string> = { onhand: "/app/stock?tab=onhand", count: "/app/stock?tab=count", locations: "/app/stock?tab=locations" };
function StockScreen() {
const router = useRouter();
const sp = useSearchParams();
const { isAdmin } = useSnap();
const raw = sp.get("tab");
const tab: StockTab = raw === "count" || raw === "locations" ? raw : "onhand";
const [newItem, setNewItem] = useState(false);
const [scanAdd, setScanAdd] = useState(false);
return (
<section>
<StockStyles />
<PageHead title="Stock">
<Seg tone="ink" label="Stock view" opts={TABS} value={tab} onChange={() => {}} labels={TAB_LABELS} hrefs={TAB_HREFS} />
{isAdmin && <button type="button" className="btn btn-onink" onClick={() => setScanAdd(true)}>Scan to add</button>}
{isAdmin && <button type="button" className="btn btn-primary" onClick={() => setNewItem(true)}>Add garment</button>}
</PageHead>
{tab === "onhand" && (
<OnHand init={{ filter: asStockFilter(sp.get("filter")), q: sp.get("q") || "", group: sp.get("group") || ALL_GROUPS, supplier: sp.get("supplier") || "" }} />
)}
{tab === "count" && <CountTab initLocation={sp.get("location") || ""} />}
{tab === "locations" && <Locations />}
{newItem && <ItemDialog onClose={() => setNewItem(false)} onSaved={(id) => router.push(`/app/stock/${encodeURIComponent(id)}`)} />}
{scanAdd && <ScanAddDialog onClose={() => setScanAdd(false)} />}
</section>
);
}
// useSearchParams wants a Suspense boundary above it.
export default function StockPage() {
return <Suspense fallback={null}><StockScreen /></Suspense>;
}
+15
View File
@@ -0,0 +1,15 @@
import { redirect } from "next/navigation";
/* The stock take moved into Stock as its Count tab. next.config.ts redirects this path too; the
stub keeps an old bookmark working (with its query) if that redirect is ever missing. */
export default async function StocktakeRedirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
const sp = await searchParams;
const q = new URLSearchParams();
for (const [k, v] of Object.entries(sp)) {
if (k === "tab") continue;
if (Array.isArray(v)) v.forEach((x) => q.append(k, x));
else if (v !== undefined) q.append(k, v);
}
const rest = q.toString();
redirect(`/app/stock?tab=count${rest ? "&" + rest : ""}`);
}
+44
View File
@@ -0,0 +1,44 @@
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 SourceNotice from "@/components/SourceNotice";
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>
{/* The AGPL's offer of source, where everyone who uses this server can see it. */}
<SourceNotice />
</main>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
/* The route-level error boundary. Something threw while rendering a page; the shell survives, so
this keeps the site's own chrome and offers the two things that actually help — try again, and
a way to tell someone.
The error's message is deliberately not printed: it is written by the server, can carry internal
detail, and means nothing to a linen services manager. The digest is shown because it is the one
string that lets a report be matched to a log line. */
import Link from "next/link";
import { HAS_SITE } from "@/lib/links";
import { useEffect } from "react";
import { reportError } from "@/lib/errors";
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => { reportError(error, "route"); }, [error]);
return (
<div style={{ fontFamily: "var(--font-body)", color: "var(--color-text)", background: "var(--color-bg)", minHeight: "100vh", display: "flex", alignItems: "center" }}>
<div style={{ maxWidth: 640, margin: "0 auto", padding: "clamp(32px,6vw,64px) clamp(20px,5vw,40px)" }}>
<div style={{ fontSize: 12, letterSpacing: "0.15em", textTransform: "uppercase", fontWeight: 800, color: "var(--color-accent-700)" }}>
Something went wrong
</div>
<h1 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(30px,5vw,52px)", lineHeight: 1.0, letterSpacing: "-0.03em", margin: "16px 0 0" }}>
This page didn&rsquo;t load.
</h1>
<div style={{ width: 60, height: 4, background: "var(--color-accent)", margin: "22px 0 0" }} />
<p style={{ fontSize: 16.5, lineHeight: 1.7, color: "var(--color-neutral-800)", margin: "22px 0 0" }}>
The fault is ours, not yours, and nothing you were doing has been lost ThreadCount only
changes a record when you commit it. Try again, and if it keeps happening, tell us and
we&rsquo;ll go and look.
</p>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", marginTop: 28 }}>
<button onClick={reset} className="btn btn-primary" style={{ cursor: "pointer", font: "inherit" }}>Try again</button>
<Link href="/" className="btn">Back to the start</Link>
{HAS_SITE && <Link href="/support" className="btn">Tell us</Link>}
</div>
{error.digest ? (
<p style={{ fontSize: 12.5, color: "var(--color-neutral-700)", marginTop: 26, fontFamily: "monospace" }}>
Reference {error.digest} quote this and we can find it in the log.
</p>
) : null}
</div>
</div>
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+70
View File
@@ -0,0 +1,70 @@
"use client";
/* The last resort: the root layout itself failed, so this replaces <html> entirely. Nothing from
the app is available here — not the font, not globals.css, not the site components — so every
style is inline and the type falls back to a system stack rather than Archivo. Keeping it
self-contained is the point: this page has to render when everything else has not. */
import { useEffect } from "react";
import { safeLocation } from "@/lib/errors";
import { report } from "@/lib/glitchtip";
const INK = "#201e1d";
const PAPER = "#f3f2f2";
const ACCENT = "#ec3013";
const SANS = "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
/* Reported straight to the reporter, not through lib/errors' window seam.
*
* That seam is published by components/ErrorReporting, which is mounted inside the root layout —
* the very layout that has just failed. When this boundary renders, that effect has by definition
* not run, so window.__tcReporter is undefined and reportError falls through to a no-op in
* production: the one boundary that means "everything is broken" was the only one sending
* nothing. Importing the reporter directly costs a couple of kilobytes in the bundle that renders
* this page and removes the dependency on a component that cannot have mounted. */
useEffect(() => { report({ error, where: "global", url: safeLocation() }); }, [error]);
return (
<html lang="en">
<body style={{ margin: 0, background: PAPER, color: INK, fontFamily: SANS }}>
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center" }}>
<div style={{ maxWidth: 620, margin: "0 auto", padding: "48px 24px" }}>
<div style={{ display: "inline-flex", alignItems: "center", gap: 10, marginBottom: 30 }}>
<span style={{ width: 15, height: 15, background: ACCENT, display: "inline-block" }} />
<span style={{ fontWeight: 800, fontSize: 19, letterSpacing: "-0.01em" }}>ThreadCount</span>
</div>
<div style={{ fontSize: 12, letterSpacing: "0.15em", textTransform: "uppercase", fontWeight: 800, color: ACCENT }}>
Service error
</div>
<h1 style={{ fontWeight: 800, fontSize: "clamp(28px,5vw,46px)", lineHeight: 1.05, letterSpacing: "-0.03em", margin: "14px 0 0" }}>
ThreadCount is having a moment.
</h1>
<div style={{ width: 60, height: 4, background: ACCENT, margin: "22px 0 0" }} />
<p style={{ fontSize: 16.5, lineHeight: 1.7, margin: "22px 0 0" }}>
Something failed before the page could be built. Your records are untouched this is
the website falling over, not the linen room. Try again in a moment.
</p>
<div style={{ marginTop: 28, display: "flex", gap: 12, flexWrap: "wrap" }}>
<button
onClick={reset}
style={{ font: "inherit", fontWeight: 800, letterSpacing: "0.02em", textTransform: "uppercase", fontSize: 13, background: ACCENT, color: "#fff", border: 0, padding: "14px 22px", cursor: "pointer" }}
>
Try again
</button>
<a
href="/"
style={{ font: "inherit", fontWeight: 800, letterSpacing: "0.02em", textTransform: "uppercase", fontSize: 13, background: "transparent", color: INK, border: `2px solid ${INK}`, padding: "12px 20px", textDecoration: "none" }}
>
Back to the start
</a>
</div>
{error.digest ? (
<p style={{ fontSize: 12.5, color: "#6b6764", marginTop: 26, fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}>
Reference {error.digest}
</p>
) : null}
</div>
</div>
</body>
</html>
);
}
+1433
View File
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
import type { Metadata } from "next";
import { Archivo, IBM_Plex_Mono } from "next/font/google";
import "./globals.css";
import ErrorReporting from "@/components/ErrorReporting";
const archivo = Archivo({
variable: "--font-archivo",
subsets: ["latin"],
weight: ["400", "600", "700", "800", "900"],
display: "swap",
});
// The marketing site sets product codes, size runs, times and cost centres in a monospace face,
// the way a coordinator reads them off a slip. Only the public pages use it (globals.css scopes
// --font-mono to .tcm-site); the app and the two phone apps never see it.
const plexMono = IBM_Plex_Mono({
variable: "--font-plex-mono",
subsets: ["latin"],
weight: ["400", "600"],
display: "swap",
});
export const viewport = { width: "device-width", initialScale: 1, viewportFit: "cover" as const };
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://threadcount.tech";
const DESC = "Uniform stock management for hospitals, aged care, clinics and community care. What's on the shelf, who took it and what it cost the ward or clinic — orders and stocktakes in one place.";
export const metadata: Metadata = {
metadataBase: new URL(SITE),
title: { default: "ThreadCount — Uniform management for hospitals, aged care and clinics", template: "%s — ThreadCount" },
description: DESC,
applicationName: "ThreadCount",
alternates: { canonical: "/" },
// Without these a link pasted into Slack, Teams or an email renders as a bare URL.
openGraph: {
type: "website", siteName: "ThreadCount", url: SITE, locale: "en_AU",
title: "ThreadCount — Uniform management for hospitals, aged care and clinics",
description: DESC,
images: [{ url: "/og.png", width: 1200, height: 630, alt: "ThreadCount — every garment out the door, accounted for." }],
},
twitter: { card: "summary_large_image", title: "ThreadCount — Uniform management for hospitals, aged care and clinics", description: DESC, images: ["/og.png"] },
robots: { index: true, follow: true },
formatDetection: { telephone: false },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${archivo.variable} ${plexMono.variable}`}>
<body>
{children}
{/* Global handlers for the client crashes that never reach a React boundary. */}
<ErrorReporting />
</body>
</html>
);
}
+466
View File
@@ -0,0 +1,466 @@
"use client";
/* The product card, on the phone.
*
* Two halves, because they are two different jobs. The top is the garment's description, which is
* typed once and rarely changed. The bottom is per size — par level, barcode, what's on hand —
* which is what someone standing at a shelf actually came here to adjust.
*
* The size index — a position in that list — is what every issue, order line and barcode points at,
* so the order of the list is never offered for editing: shuffling it would silently repoint years
* of records. One size can be taken off, though, and the server does the deciding: it shifts every
* later size down across the ten tables that store a position, in one transaction, and refuses
* outright when the size being removed has anything recorded against it. So this screen offers the
* removal on every size and shows whatever comes back.
*
* `?bind=<code>` arrives from a scan that found nothing: each size offers to take that code. */
import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { bcBound, formatInZone, key as vkey, label, money, onhand, reorderAt, splitKey, type CostRec, type Item } from "@/lib/compute";
import { printItemLabels } from "@/lib/nativeprint";
import MScan from "@/components/MScan";
import {
ACCENT, GROUND, INK, ON_DARK, MBar, MBody, MButton, MEmpty, MError, MField, MONO, MPill, MRule, MSection, MTop, MTopCount, inputStyle, useToast,
} from "@/components/m";
/* The two ways to get a code onto a size, side by side. Scanning stays the primary act, ink-filled:
it is the fastest when the camera cooperates. Typing sits beside it rather than behind it — a
label in your hand beats a camera that won't focus. */
const codeBtn: React.CSSProperties = {
flex: 1, minHeight: 48, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800,
fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer",
};
/* Undoing rather than doing: the quieter kind of action on a size row. No border, so it reads as a
link; 44px tall, so it is still a target you can hit with gloves on. */
const quietAction: React.CSSProperties = {
display: "flex", alignItems: "center", width: "100%", minHeight: 44, background: "none", border: 0,
padding: 0, font: "inherit", fontSize: 13, fontWeight: 700, color: "var(--color-neutral-700)",
textAlign: "left", cursor: "pointer",
};
/* A freshly minted number exists in ThreadCount at once, but nothing is on the garment until it is
printed, so the confirmation carries the print with it. */
function MMade({ made, labels, onPrint }: { made: { size: string; code: string }[]; labels: number; onPrint: () => void }) {
if (!made.length) return null;
return (
<div style={{ margin: "10px 0 0", padding: 16, background: INK, color: GROUND, fontSize: 14 }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 16 }}>
{made.length === 1 ? `Size ${made[0].size} has a barcode` : `${made.length} sizes have a barcode`}
</div>
<div style={{ marginTop: 8, color: ON_DARK, fontSize: 13, fontFamily: MONO }}>
{made.map((m) => <div key={m.code}>{m.size} · {m.code}</div>)}
</div>
{labels > 0 && (
<button onClick={onPrint}
style={{ width: "100%", minHeight: 48, marginTop: 12, border: "2px solid " + GROUND, background: GROUND, color: INK, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer" }}>
Print {labels} label{labels === 1 ? "" : "s"}
</button>
)}
</div>
);
}
export default function MProductCard() {
const { id } = useParams<{ id: string }>();
const { s, isAdmin, mutate, busy } = useSnap();
const { L, byId } = useDerived();
const router = useRouter();
const sp = useSearchParams();
const toast = useToast();
const bindRaw = (sp.get("bind") || "").trim().slice(0, 80);
const bindCode = isAdmin ? bindRaw : "";
const it = s.catalog.find((x: Item) => x.id === id);
const [err, setErr] = useState("");
const [editing, setEditing] = useState(false);
const [f, setF] = useState(() => ({
item: it?.item ?? "", type: it?.type ?? "", group: it?.group ?? "All",
supplier: it?.supplier ?? "", sku: it?.sku ?? "", cost: it ? String(it.cost) : "", notes: it?.notes ?? "",
}));
const [newSize, setNewSize] = useState("");
const [scanFor, setScanFor] = useState<number | null>(null);
const [typeFor, setTypeFor] = useState<number | null>(null);
const [typed, setTyped] = useState("");
/* Which run is in flight, so only the button that was pressed says so. -1 is the whole garment. */
const [genFor, setGenFor] = useState<number | null>(null);
const [made, setMade] = useState<{ si: number; size: string; code: string }[]>([]);
// Binding is admin-only; an Issuer who arrives with a code goes back to the catalogue.
useEffect(() => { if (bindRaw && !isAdmin) router.replace("/m/catalogue"); }, [bindRaw, isAdmin, router]);
const groups = useMemo(() => {
const set = new Set<string>(["All"]);
for (const st of s.staff) if (st.group) set.add(st.group);
for (const i of s.catalog) if (i.group) set.add(i.group);
return [...set].sort();
}, [s.staff, s.catalog]);
if (!it) {
return (
<>
<MTop title="Garment" back />
<MRule />
<MBody pad><MEmpty title="No such garment" sub="It isnt in the catalogue any more." /></MBody>
</>
);
}
const name = label(byId[it.id] ?? it);
// Newest first; the snapshot already caps how many it carries.
const costs: CostRec[] = s.costs.filter((c) => c.itemId === it.id);
const readOnly = !isAdmin;
// How many sizes a whole-garment run would cover, and how many labels a print run would produce —
// one per garment on the shelf in a size that carries a code.
const unlabelled = it.sizes.filter((_: string, si: number) => !bcBound(s, it, si)).length;
const labels = it.sizes.reduce((n: number, _: string, si: number) =>
n + (bcBound(s, it, si) ? Math.max(0, onhand(s, L, vkey(it.id, si))) : 0), 0);
/* Fill the boxes from the garment as it stands right now, not as it stood when the screen was
* opened: the card refreshes underneath without remounting, and saving a stale form would put an
* old price back in someone else's name. */
function startEdit() {
setErr("");
setF({
item: it!.item, type: it!.type, group: it!.group,
supplier: it!.supplier, sku: it!.sku, cost: String(it!.cost), notes: it!.notes,
});
setEditing(true);
}
async function saveDetails() {
setErr("");
if (!f.item.trim()) { setErr("The garment needs a name."); return; }
const c = f.cost.trim() ? Number(f.cost) : 0;
if (!(c >= 0) || Number.isNaN(c)) { setErr("Cost has to be a number."); return; }
const r = await mutate("catalog.update", {
id: it!.id, item: f.item.trim(), type: f.type.trim(), group: f.group,
supplier: f.supplier.trim(), sku: f.sku.trim(), cost: c, notes: f.notes,
});
if (!r.ok) { setErr(r.error); return; }
setEditing(false);
}
async function addSize() {
const sz = newSize.trim();
if (!sz) return;
setErr("");
const r = await mutate("catalog.update", { id: it!.id, addSize: sz });
if (!r.ok) { setErr(r.error); return; }
setNewSize("");
}
async function setPar(si: number, next: number) {
const r = await mutate("stock.reorder", { itemId: it!.id, si, reorder: Math.max(0, next) });
if (!r.ok) setErr(r.error);
}
/** Where a code already sits, named the way a person would name it, or "" if this snapshot has
* never seen it. */
function boundElsewhere(code: string): string {
const at = s.barcodes[code];
if (!at) return "";
const { itemId, si } = splitKey(at);
const other = byId[itemId];
return other ? `${label(other)} · size ${other.sizes[si] ?? si}` : "";
}
/* Binding, including the refusal that used to be a dead end: when the code already sits on another
* garment, offer the move rather than printing the message and stopping there. A generated
* 93XXXXXXX code is refused with or without force and is shown as it came. */
async function bind(si: number, raw: string): Promise<boolean> {
const code = raw.trim();
if (!code) return false;
setErr(""); setMade([]);
const r = await mutate("barcode.bind", { code, itemId: it!.id, si });
if (r.ok) { setTypeFor(null); setTyped(""); return true; }
const at = boundElsewhere(code);
if (!at && !/is already on/.test(r.error)) { setErr(r.error); return false; }
const ask = at
? `${code} is on ${at}. Take it off there and put it on ${name} · size ${it!.sizes[si]}?`
: `${r.error}\n\nMove it onto ${name} · size ${it!.sizes[si]}?`;
if (!confirm(ask)) { setErr(r.error); return false; }
const moved = await mutate("barcode.bind", { code, itemId: it!.id, si, force: true });
if (!moved.ok) { setErr(moved.error); return false; }
setTypeFor(null); setTyped("");
return true;
}
async function bindScanned(si: number) {
if (!(await bind(si, bindCode))) return;
toast(`${bindCode} bound to size ${it!.sizes[si]}`);
router.replace(`/m/catalogue/${encodeURIComponent(it!.id)}`);
}
async function unbind(si: number, code: string) {
if (!confirm(`Unbind ${code} from ${name} · size ${it!.sizes[si]}? Scanning that label won't find this size any more.`)) return;
setErr(""); setMade([]);
const r = await mutate("barcode.unbind", { code });
if (!r.ok) setErr(r.error);
}
/* The server decides whether a size can go; removing shifts later sizes down a place, so anything
* held open against a position lets go of it. */
async function removeSize(si: number) {
if (!confirm(`Remove size ${it!.sizes[si]} from ${name}? Its par level and any barcode on it go with it.`)) return;
setErr("");
const r = await mutate("catalog.removeSize", { id: it!.id, si });
if (!r.ok) { setErr(r.error); return; }
setScanFor(null); setTypeFor(null); setTyped(""); setMade([]);
}
/* Our own barcode for stock that arrived without one. The server fills only the gaps and refuses
* when there is nothing to do, so the offer is made and whatever comes back is shown. */
async function generate(si?: number) {
setErr(""); setMade([]);
setGenFor(si ?? -1);
const r = await mutate<{ made: { si: number; size: string; code: string }[]; count: number }>(
"barcode.generate", si === undefined ? { itemId: it!.id } : { itemId: it!.id, si },
);
setGenFor(null);
if (!r.ok) { setErr(r.error); return; }
setMade(r.result.made);
}
async function generateAll() {
const ask = `Generate a barcode for ${unlabelled} size${unlabelled === 1 ? "" : "s"} on ${name}? Sizes that already carry a supplier's code keep theirs, and nothing is on a garment until the labels are printed.`;
if (unlabelled > 0 && !confirm(ask)) return;
await generate();
}
/* A whole garment's labels: one per garment on hand, every size that carries a code. Android
printing in the app; a printable page in a browser. */
async function printLabels() {
const r = await printItemLabels({ itemId: it!.id });
if (!r.ok) setErr(r.error);
}
async function archive() {
const r = await mutate("catalog.update", { id: it!.id, archived: !it!.archived });
if (!r.ok) { setErr(r.error); return; }
if (!it!.archived) router.replace("/m/catalogue");
}
const bigBtn = (tone: "line" | "accent" | "ink"): React.CSSProperties => ({
width: "100%", minHeight: 52, border: "2px solid " + (tone === "accent" ? ACCENT : INK),
background: tone === "accent" ? ACCENT : tone === "ink" ? INK : "transparent", color: tone === "line" ? INK : tone === "ink" ? GROUND : "#fff",
font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer",
});
return (
<>
<MTop title={it.archived ? "Archived" : "Garment"} right={<MTopCount>{it.sizes.length} size{it.sizes.length === 1 ? "" : "s"}</MTopCount>} back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
{(it.archived || bindCode) && (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 10 }}>
{it.archived && <MPill tone="accent">Archived</MPill>}
{bindCode && <MPill tone="accent" mono>Binding {bindCode}</MPill>}
</div>
)}
{/* ---- the description ---- */}
{!editing ? (
<>
<div style={{ paddingBottom: 14, borderBottom: "1px solid var(--color-divider)" }}>
<h2 style={{ fontWeight: 900, fontSize: 24, lineHeight: 1.1, margin: 0 }}>{name}</h2>
<div style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 6 }}>
{[it.type, it.group === "All" ? "Anyone" : it.group, it.supplier, it.sku].filter(Boolean).join(" · ") || "No details yet"}
</div>
<div style={{ fontSize: 14, fontFamily: MONO, marginTop: 4 }}>{it.cost ? `${money(it.cost)} each` : "No unit cost"}</div>
{it.notes && <div style={{ fontSize: 13, color: "var(--color-neutral-800)", marginTop: 8 }}>{it.notes}</div>}
</div>
{!readOnly && !bindCode && (
<div style={{ padding: "14px 0", borderBottom: "2px solid " + INK }}>
<button onClick={startEdit} style={bigBtn("line")}>Edit details</button>
</div>
)}
</>
) : (
<>
<MField label="Garment">
<input value={f.item} onChange={(e) => setF({ ...f, item: e.target.value })} autoCapitalize="words" style={inputStyle} />
</MField>
<MField label="Type">
<input value={f.type} onChange={(e) => setF({ ...f, type: e.target.value })} style={inputStyle} />
</MField>
<MField label="Who wears it">
<select value={f.group} onChange={(e) => setF({ ...f, group: e.target.value })} style={{ ...inputStyle, appearance: "none" }}>
{groups.map((g) => <option key={g} value={g}>{g === "All" ? "Anyone" : g}</option>)}
</select>
</MField>
<MField label="Supplier">
<input value={f.supplier} onChange={(e) => setF({ ...f, supplier: e.target.value })} style={inputStyle} />
</MField>
<MField label="Supplier code">
<input value={f.sku} onChange={(e) => setF({ ...f, sku: e.target.value })} autoCapitalize="characters" autoCorrect="off" spellCheck={false} style={inputStyle} />
</MField>
<MField label="Unit cost">
<input value={f.cost} onChange={(e) => setF({ ...f, cost: e.target.value })} inputMode="decimal" style={inputStyle} />
</MField>
<MField label="Notes">
<input value={f.notes} onChange={(e) => setF({ ...f, notes: e.target.value })} placeholder="Optional" style={inputStyle} />
</MField>
<div style={{ padding: "14px 0", display: "grid", gap: 10, borderBottom: "2px solid " + INK }}>
<button onClick={saveDetails} disabled={busy} style={bigBtn("accent")}>{busy ? "Saving…" : "Save details"}</button>
<button onClick={() => { setEditing(false); setErr(""); setF({ item: it.item, type: it.type, group: it.group, supplier: it.supplier, sku: it.sku, cost: String(it.cost), notes: it.notes }); }}
style={{ width: "100%", minHeight: 48, border: 0, background: "none", color: "var(--color-neutral-700)", font: "inherit", fontSize: 14, fontWeight: 700, cursor: "pointer" }}>
Cancel
</button>
</div>
</>
)}
{/* ---- per size ---- */}
<MSection label="Sizes" right="on hand · par" />
{it.sizes.map((sz: string, si: number) => {
const k = vkey(it.id, si);
const oh = onhand(s, L, k);
const par = reorderAt(s, k);
// The bound supplier code only; bcFor()'s generated fallback is printed on no garment.
const code = bcBound(s, it, si);
return (
<div key={si} style={{ padding: "14px 0", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ fontFamily: MONO, fontWeight: 600, fontSize: 18, minWidth: 54 }}>{sz}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: "var(--color-neutral-700)" }}>
<b style={{ color: oh <= par ? "var(--color-accent-700)" : INK, fontSize: 15, fontFamily: MONO }}>{oh}</b> on hand
</div>
<div style={{ fontSize: 12, fontFamily: MONO, color: code ? "var(--color-neutral-700)" : "var(--color-neutral-600)", marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{code || "No barcode bound"}
</div>
</div>
{!readOnly && (
<div style={{ display: "flex", alignItems: "center", gap: 0 }}>
<button onClick={() => setPar(si, par - 1)} aria-label={`Lower par for ${sz}`}
style={{ width: 44, height: 44, border: "2px solid " + INK, background: "transparent", color: INK, fontSize: 20, fontWeight: 800, cursor: "pointer" }}></button>
<div aria-label={`Par for ${sz}: ${par}`} style={{ minWidth: 44, height: 44, background: INK, color: GROUND, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: MONO, fontSize: 16 }}>{par}</div>
<button onClick={() => setPar(si, par + 1)} aria-label={`Raise par for ${sz}`}
style={{ width: 44, height: 44, border: "2px solid " + INK, background: "transparent", color: INK, fontSize: 20, fontWeight: 800, cursor: "pointer" }}>+</button>
</div>
)}
</div>
{!readOnly && bindCode && code !== bindCode && (
<MButton small tone="ink" label={`Bind to size ${sz}`} disabled={busy} onClick={() => bindScanned(si)} />
)}
{!readOnly && !bindCode && (
<div style={{ marginTop: 10 }}>
{typeFor === si ? (
<div style={{ display: "grid", gap: 8 }}>
{/* A numeric keypad hint only: whatever arrives is taken as typed. */}
<input value={typed} onChange={(e) => setTyped(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") bind(si, typed); }}
placeholder="Barcode on the label" inputMode="numeric" autoFocus
autoCapitalize="characters" autoCorrect="off" spellCheck={false}
aria-label={`Barcode for size ${sz}`} style={inputStyle} />
<div style={{ display: "flex", gap: 8 }}>
<button onClick={() => bind(si, typed)} disabled={busy || !typed.trim()}
style={{ ...codeBtn, border: "2px solid " + ACCENT, background: ACCENT, color: "#fff", opacity: typed.trim() ? 1 : 0.4 }}>
{busy ? "Binding…" : "Bind"}
</button>
<button onClick={() => { setTypeFor(null); setTyped(""); }}
style={{ ...codeBtn, border: "2px solid var(--color-neutral-400)", background: "transparent", color: "var(--color-neutral-700)" }}>
Cancel
</button>
</div>
</div>
) : (
<>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={() => setScanFor(si)} aria-label={`Scan a barcode for size ${sz}`}
style={{ ...codeBtn, border: "2px solid " + INK, background: INK, color: GROUND }}>
{code ? "Scan a new one" : "Scan"}
</button>
<button onClick={() => { setErr(""); setTyped(""); setTypeFor(si); }} aria-label={`Type a barcode for size ${sz}`}
style={{ ...codeBtn, border: "2px solid " + INK, background: "transparent", color: INK }}>
Type it in
</button>
</div>
{/* Offered only where nothing is bound: a supplier's printed code always stays. */}
{!code && (
<button onClick={() => generate(si)} disabled={busy} aria-label={`Generate a barcode for size ${sz}`}
style={{ ...codeBtn, width: "100%", marginTop: 8, border: "2px solid " + INK, background: "transparent", color: INK, opacity: busy ? 0.5 : 1 }}>
{genFor === si ? "Generating…" : "Generate a barcode"}
</button>
)}
</>
)}
{code && <button onClick={() => unbind(si, code)} style={quietAction}>Unbind {code}</button>}
<button onClick={() => removeSize(si)} style={quietAction}>Remove size {sz}</button>
{made.length === 1 && made[0].si === si && <MMade made={made} labels={labels} onPrint={printLabels} />}
</div>
)}
</div>
);
})}
{!readOnly && !bindCode && (
<div style={{ padding: "14px 0", borderBottom: "2px solid " + INK, display: "flex", gap: 10 }}>
<input value={newSize} onChange={(e) => setNewSize(e.target.value)} placeholder="Add a size" aria-label="New size"
autoCapitalize="characters" autoCorrect="off" spellCheck={false}
style={{ ...inputStyle, flex: 1 }} />
<button onClick={addSize} disabled={busy || !newSize.trim()}
style={{ minWidth: 96, border: "2px solid " + INK, background: INK, color: GROUND, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer", opacity: newSize.trim() ? 1 : 0.4 }}>
Add
</button>
</div>
)}
{!readOnly && !bindCode && (
<>
<MSection label="Barcodes" right={unlabelled ? `${unlabelled} without` : "All labelled"} />
{made.length > 1 && <MMade made={made} labels={labels} onPrint={printLabels} />}
<div style={{ padding: "14px 0", display: "grid", gap: 10, borderBottom: "2px solid " + INK }}>
<button onClick={generateAll} disabled={busy} style={{ ...bigBtn("line"), opacity: busy ? 0.5 : 1 }}>
{genFor === -1 ? "Generating…" : "Generate for every unlabelled size"}
</button>
<button onClick={printLabels} disabled={labels === 0}
style={{ ...bigBtn("accent"), cursor: labels === 0 ? "not-allowed" : "pointer", opacity: labels === 0 ? 0.4 : 1 }}>
{labels ? `Print ${labels} label${labels === 1 ? "" : "s"}` : "Nothing to print"}
</button>
</div>
</>
)}
{!readOnly && !bindCode && (
<div style={{ padding: "14px 0" }}>
<button onClick={archive} style={{ ...quietAction, color: it.archived ? "var(--color-accent-700)" : "var(--color-neutral-700)" }}>
{it.archived ? "Put this garment back in the catalogue" : "Archive this garment"}
</button>
</div>
)}
{/* What we used to pay: a price rise would otherwise erase the old figure. */}
{costs.length > 0 && (
<>
<MSection label="What it has cost" right="changed by" />
{costs.map((c) => (
<div key={c.id} style={{ display: "flex", alignItems: "baseline", gap: 12, padding: "12px 0", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ fontFamily: MONO, fontWeight: 600, fontSize: 15, minWidth: 78 }}>{money(c.cost)}</div>
<div style={{ flex: 1, minWidth: 0, fontSize: 13, color: "var(--color-neutral-700)" }}>
{c.previous === null ? "Opening price" : `${c.previous > c.cost ? "Down" : "Up"} from ${money(c.previous)}`}
{" · "}
{/* The facility's zone, not the device's, so server and browser render the same day. */}
{formatInZone(c.at, s.tz)}
</div>
<div style={{ fontSize: 12, color: "var(--color-neutral-600)", whiteSpace: "nowrap" }}>{c.byName}</div>
</div>
))}
</>
)}
</MBody>
{!readOnly && !it.archived && !bindCode && <MBar label="Done" glyph="check" tone="ink" onClick={() => router.push("/m/catalogue")} />}
{scanFor !== null && (
<MScan
title={`Barcode for ${it.sizes[scanFor]}`}
onClose={() => setScanFor(null)}
onHit={(raw) => { const si = scanFor; setScanFor(null); if (si !== null) bind(si, raw); }}
/>
)}
</>
);
}
+136
View File
@@ -0,0 +1,136 @@
"use client";
/* Create a garment from the counter.
*
* The only genuinely required things are a name and at least one size — the server enforces exactly
* that — so everything else can be filled in later from the product card. Sizes are entered as a
* run rather than one at a time, because that is how they arrive. */
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useSnap } from "@/lib/client";
import type { Item } from "@/lib/compute";
import { INK, MBar, MBody, MEmpty, MError, MField, MRule, MTop, inputStyle } from "@/components/m";
const COMMON_RUNS: [string, string][] = [
["XS S M L XL", "XS · S · M · L · XL"],
["S M L XL 2XL", "S · M · L · XL · 2XL"],
["8 10 12 14 16 18", "8 18"],
["77R 82R 87R 92R", "77R 92R"],
];
export default function MCatalogueNew() {
const { s, isAdmin, mutate, busy } = useSnap();
const router = useRouter();
const [item, setItem] = useState("");
const [type, setType] = useState("");
const [group, setGroup] = useState("All");
const [supplier, setSupplier] = useState("");
const [sku, setSku] = useState("");
const [cost, setCost] = useState("");
const [sizeText, setSizeText] = useState("");
const [err, setErr] = useState("");
// Split on commas, slashes or whitespace so a run can be typed however it comes to hand.
const sizes = useMemo(
() => sizeText.split(/[,/\s]+/).map((x) => x.trim()).filter(Boolean),
[sizeText],
);
const dupSize = useMemo(() => sizes.length !== new Set(sizes).size, [sizes]);
/* The facility's configured groups are the vocabulary; groups already in use on the register or
* the catalogue are folded in so none that predates the configured list vanishes. */
const groups = useMemo(() => {
const set = new Set<string>(["All", ...s.settings.staffGroups]);
for (const st of s.staff) if (st.group) set.add(st.group);
for (const i of s.catalog) if (i.group) set.add(i.group);
return [...set].sort();
}, [s.staff, s.catalog, s.settings.staffGroups]);
const types = useMemo(() => [...new Set(s.catalog.map((i: Item) => i.type).filter(Boolean))].sort(), [s.catalog]);
const suppliers = useMemo(() => s.supplierDir.map((x) => x.name).sort(), [s.supplierDir]);
if (!isAdmin) {
return (
<>
<MTop title="New garment" back />
<MRule />
<MBody pad><MEmpty title="Admins only" sub="An admin adds garments to the catalogue." /></MBody>
</>
);
}
async function save() {
setErr("");
if (!item.trim()) { setErr("Give the garment a name."); return; }
if (!sizes.length) { setErr("Add at least one size."); return; }
if (dupSize) { setErr("The same size is listed twice."); return; }
const c = cost.trim() ? Number(cost) : 0;
if (!(c >= 0) || Number.isNaN(c)) { setErr("Cost has to be a number, or left blank."); return; }
const r = await mutate<{ id: string }>("catalog.add", {
item: item.trim(), type: type.trim(), group, supplier: supplier.trim(),
sku: sku.trim(), cost: c, sizes,
});
if (!r.ok) { setErr(r.error); return; }
// Straight to the product card: the next thing anyone does is bind a barcode or set par.
router.replace(`/m/catalogue/${r.result.id}`);
}
return (
<>
<MTop title="New garment" back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
<MField label="Garment">
<input value={item} onChange={(e) => { setItem(e.target.value); setErr(""); }}
placeholder="Scrub top" autoCapitalize="words" enterKeyHint="next" style={inputStyle} />
</MField>
<MField label="Sizes">
<input value={sizeText} onChange={(e) => { setSizeText(e.target.value); setErr(""); }}
placeholder="S M L XL" autoCapitalize="characters" autoCorrect="off" spellCheck={false} style={inputStyle} />
</MField>
<div role="group" aria-label="Common size runs" style={{ paddingBottom: 12, display: "flex", flexWrap: "wrap", gap: 8 }}>
{COMMON_RUNS.map(([run, pretty]) => (
<button key={run} type="button" aria-pressed={sizeText === run} onClick={() => { setSizeText(run); setErr(""); }}
style={{ minHeight: 44, border: "2px solid " + INK, background: sizeText === run ? INK : "transparent", color: sizeText === run ? "var(--color-bg)" : INK, padding: "0 12px", fontFamily: "inherit", fontSize: 13, fontWeight: 700, cursor: "pointer" }}>
{pretty}
</button>
))}
</div>
{sizes.length > 0 && (
<div style={{ paddingBottom: 12, fontSize: 13, color: dupSize ? "var(--color-accent-700)" : "var(--color-neutral-700)", fontWeight: dupSize ? 700 : 400 }}>
{dupSize ? "The same size is listed twice." : `${sizes.length} size${sizes.length === 1 ? "" : "s"}: ${sizes.join(" · ")}`}
</div>
)}
<MField label="Type">
<input list="tc-types" value={type} onChange={(e) => setType(e.target.value)} placeholder="Scrub top" style={inputStyle} />
<datalist id="tc-types">{types.map((t) => <option key={t} value={t} />)}</datalist>
</MField>
<MField label="Who wears it">
<select value={group} onChange={(e) => setGroup(e.target.value)} style={{ ...inputStyle, appearance: "none" }}>
{groups.map((g) => <option key={g} value={g}>{g === "All" ? "Anyone" : g}</option>)}
</select>
</MField>
<MField label="Supplier">
<input list="tc-suppliers" value={supplier} onChange={(e) => setSupplier(e.target.value)} placeholder="Optional" style={inputStyle} />
<datalist id="tc-suppliers">{suppliers.map((x) => <option key={x} value={x} />)}</datalist>
</MField>
<MField label="Supplier code">
<input value={sku} onChange={(e) => setSku(e.target.value)} placeholder="Optional" autoCapitalize="characters" autoCorrect="off" spellCheck={false} style={inputStyle} />
</MField>
<MField label="Unit cost">
<input value={cost} onChange={(e) => { setCost(e.target.value); setErr(""); }}
inputMode="decimal" placeholder="0.00" style={inputStyle} />
</MField>
</MBody>
<MBar label={busy ? "Saving…" : "Create garment"} onClick={save} disabled={busy} />
</>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
/* The whole catalogue on the phone, not just what's on the shelf: Stock lists only sizes with
* history, so a garment created five minutes ago lives here. `?bind=<code>` arrives from a scan that
* found nothing; picking a garment carries the code to its product card (admins only). */
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { label, money, type Item } from "@/lib/compute";
import { MBody, MButton, MEmpty, MPill, MRow, MRule, MSearch, MSection, MTop, MTopCount } from "@/components/m";
const PAGE = 300;
export default function MCatalogue() {
const { s, isAdmin } = useSnap();
const { byId } = useDerived();
const router = useRouter();
const sp = useSearchParams();
const bindRaw = (sp.get("bind") || "").trim().slice(0, 80);
const bind = isAdmin ? bindRaw : "";
const [q, setQ] = useState("");
const [showArchived, setShowArchived] = useState(false);
const [shown, setShown] = useState(PAGE);
// Binding is an admin job; an Issuer arriving with a code just gets the catalogue.
useEffect(() => { if (bindRaw && !isAdmin) router.replace("/m/catalogue"); }, [bindRaw, isAdmin, router]);
const rows = useMemo(() => {
const needle = q.trim().toLowerCase();
return s.catalog
.filter((i: Item) => (showArchived ? i.archived : !i.archived))
.map((i: Item) => ({
...i,
name: label(byId[i.id] ?? i),
sub: [i.sizes.length ? `${i.sizes.length} size${i.sizes.length === 1 ? "" : "s"}` : "No sizes", i.supplier, i.sku].filter(Boolean).join(" · "),
}))
.filter((i) => !needle || `${i.name} ${i.sku} ${i.supplier} ${i.type} ${i.group}`.toLowerCase().includes(needle))
.sort((a, b) => a.name.localeCompare(b.name));
}, [s.catalog, byId, q, showArchived]);
const archivedCount = s.catalog.filter((i: Item) => i.archived).length;
const cardHref = (id: string) => `/m/catalogue/${encodeURIComponent(id)}${bind ? `?bind=${encodeURIComponent(bind)}` : ""}`;
return (
<>
<MTop title="Catalogue" back right={<MTopCount>{rows.length}</MTopCount>} />
<MRule />
<MBody pad>
<MSearch value={q} onChange={(v) => { setQ(v); setShown(PAGE); }} placeholder="Garment, code, supplier" label="Filter the catalogue" />
{bind && <div style={{ marginTop: 10 }}><MPill tone="accent" mono>Binding {bind}</MPill></div>}
{isAdmin && !bind && <MButton icon="plus" label="New garment" href="/m/catalogue/new" />}
<MSection label={showArchived ? "Archived" : "Garments"} right={rows.length} />
{rows.length === 0
? <MEmpty title={q ? "Nothing matches" : showArchived ? "Nothing archived" : "No garments yet"}
sub={q ? "Try a shorter search." : undefined} />
: rows.slice(0, shown).map((i) => (
<MRow key={i.id} href={isAdmin ? cardHref(i.id) : undefined} chev={isAdmin}
mark={i.archived ? "mute" : "ink"} title={i.name} sub={i.sub}
right={i.cost ? money(i.cost) : undefined} />
))}
{rows.length > shown && (
<>
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 10 }}>Showing {shown} of {rows.length}</div>
<MButton small label="Show more" onClick={() => setShown((n) => n + PAGE)} />
</>
)}
{archivedCount > 0 && (
<MButton small label={showArchived ? "Current catalogue" : `Show ${archivedCount} archived`}
onClick={() => { setShowArchived(!showArchived); setShown(PAGE); }} />
)}
</MBody>
</>
);
}
+210
View File
@@ -0,0 +1,210 @@
"use client";
/* Counting: scan a garment and its own line goes up by one and becomes the line being counted.
Expected figures stay on screen (a sighted count, as the phone has always been). The tally lives
in localStorage (lib/opencount.ts), so backgrounding the app mid-shelf loses nothing.
Hands-free keeps the camera reading continuously. Inside the Android shell MLKit runs with its
preview behind this opaque screen, so the panel and the list stay in view and each garment is a
beep, a buzz and the figures moving (handsfree.png). In a browser the camera needs a visible,
playing <video>, so hands-free opens the live camera overlay with the same panel and switch. */
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSnap } from "@/lib/client";
import { UNPLACED } from "@/lib/compute";
import MScan from "@/components/MScan";
import { isNative, startLive } from "@/lib/nativescan";
import { scanReject, scanTick } from "@/lib/feedback";
import { track } from "@/lib/analytics";
import { useKeepAwake } from "@/lib/wakelock";
import { SHELF_LABEL_PREFIX } from "@/lib/scanroute";
import { readCount, writeCount } from "@/lib/opencount";
import { GROUND, INK, MAction, MBody, MButton, MEmpty, MError, MRow, MRule, MSection, MSplit, MTop, MTopAction } from "@/components/m";
import { CountFigures, CountPanel, HandsFree, TypeCount } from "@/components/m/count/CountPanel";
import { lineTitle, useCountLines } from "@/components/m/count/lines";
const DEBOUNCE_MS = 900;
export default function MCounting() {
const { s } = useSnap();
const router = useRouter();
const locationId = String(useParams().id || "");
const { lines, locName, locs } = useCountLines(locationId);
const [counted, setCounted] = useState<Record<string, number>>({});
// Held by variant key, never by position: the list is rebuilt on every live refresh.
const [activeKey, setActiveKey] = useState("");
const [single, setSingle] = useState(false);
const [hands, setHands] = useState(false);
// "inline": MLKit behind this screen. "overlay": the live camera overlay (browser, or a shell without MLKit).
const [handsMode, setHandsMode] = useState<"inline" | "overlay">("inline");
const [typing, setTyping] = useState(false);
const [log, setLog] = useState<string[]>([]);
const [err, setErr] = useState("");
const [loaded, setLoaded] = useState(false);
// Restore this person's open count of this shelf, once per shelf. The key includes the user: the
// phone is shared. It deliberately does not re-run on `lines`, so a size moved off the shelf from
// the desktop mid-count keeps what was already counted against it.
const me = s.session.userId;
useEffect(() => {
setCounted(readCount(me, locationId)?.n ?? {});
// Recount from Check the gaps lands here with ?line=<key>.
try {
const line = new URLSearchParams(window.location.search).get("line");
if (line) setActiveKey(line);
} catch { /* no query to read */ }
setLoaded(true);
}, [me, locationId]);
useEffect(() => {
if (!loaded) return;
writeCount(me, locationId, counted);
}, [counted, me, locationId, loaded]);
useKeepAwake(true);
const total = lines.reduce((t, l) => t + (counted[l.key] ?? 0), 0);
const expectedAll = lines.reduce((t, l) => t + l.expected, 0);
const cur = lines.find((l) => l.key === activeKey) || lines[0];
const curName = cur ? `${lineTitle(cur)} · ${cur.size}` : "";
const curN = cur ? counted[cur.key] ?? 0 : 0;
const bump = useCallback((k: string, by: number) => {
setCounted((c) => ({ ...c, [k]: Math.max(0, (c[k] ?? 0) + by) }));
}, []);
/** A scanned code lands on its own line, whichever line was active: the barcode is the truth. */
const onCode = useCallback((raw: string) => {
const code = raw.trim();
if (!code) return;
const hit = s.barcodes[code];
const line = hit ? lines.find((l) => l.key === hit) : lines.find((l) => l.code === code);
if (!line) {
scanReject();
const known = Object.prototype.hasOwnProperty.call(s.barcodes, code);
track("scan_miss", { kind: known ? "wrong_shelf" : "unknown" });
const shelf = code.startsWith(SHELF_LABEL_PREFIX) ? locs[code.slice(SHELF_LABEL_PREFIX.length)] : undefined;
const placedAt = hit ? locs[s.placed[hit] || ""]?.name : "";
setErr(shelf ? `Thats the label for ${shelf.name}`
: !known ? `${code} isnt a garment ThreadCount knows`
: placedAt ? `${code} is on ${placedAt}, not this shelf`
: locationId === UNPLACED ? `${code} isnt in this count` : `${code} isnt on a shelf yet`);
setLog((g) => [`${code}: not on this shelf`, ...g].slice(0, 8));
return;
}
setActiveKey(line.key);
setTyping(false);
bump(line.key, 1);
setErr("");
setLog((g) => [`${lineTitle(line)} ${line.size}`, ...g].slice(0, 8));
}, [s.barcodes, s.placed, lines, locs, locationId, bump]);
const onCodeRef = useRef(onCode); onCodeRef.current = onCode;
// Hands-free inside the Android shell: MLKit reads continuously behind this screen. This screen's
// root carries `tcx-scanui` while it runs, so globals.css leaves it visible and opaque.
const [inlineLive, setInlineLive] = useState(false);
useEffect(() => {
if (!hands || handsMode !== "inline") return;
if (!isNative()) { setHandsMode("overlay"); return; }
let session: { stop: () => Promise<void> } | null = null;
let cancelled = false;
let lastRaw = "", lastT = 0;
setInlineLive(true);
(async () => {
const r = await startLive((raw) => {
if (raw === lastRaw && Date.now() - lastT < DEBOUNCE_MS) return;
lastRaw = raw; lastT = Date.now();
scanTick();
onCodeRef.current(raw);
});
if (cancelled) { await r.stop(); return; }
if (r.error === "native-unavailable") { setInlineLive(false); setHandsMode("overlay"); return; }
if (r.error) { setInlineLive(false); setHands(false); setErr(r.error); return; }
session = r;
})();
track("scan_opened", { mode: "live", engine: "mlkit-inline" });
return () => { cancelled = true; setInlineLive(false); if (session) session.stop(); };
}, [hands, handsMode]);
const toggleHands = () => {
setTyping(false);
setSingle(false);
setHands((h) => !h);
};
if (loaded && !lines.length) {
return (
<>
<MTop title={locName} back />
<MRule />
<MBody pad><MEmpty title="Nothing on this shelf" sub="Place sizes on it in the portal" /></MBody>
</>
);
}
const panelInner = cur ? <CountFigures name={curName} counted={curN} expected={cur.expected} /> : null;
return (
// display: contents keeps every piece a direct flex child of .tcx-app as before; the class is what
// spares this screen while MLKit's preview runs behind the WebView.
<div className={inlineLive ? "tcx-scanui" : undefined} style={{ display: "contents" }}>
<MTop title={locName} back right={<MTopAction label="Finish" onClick={() => { setHands(false); router.push(`/m/count/${locationId}/variance`); }} />} />
<MRule n={total} of={expectedAll} />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
{cur && (
<CountPanel>
{panelInner}
<HandsFree on={hands} onToggle={toggleHands} />
{typing && (
<TypeCount key={cur.key} name={curName} value={curN}
onSet={(n) => { setCounted((c) => ({ ...c, [cur.key]: n })); setTyping(false); }} />
)}
</CountPanel>
)}
<MSection label="Lines" right={`${total} of ${expectedAll}`} />
{lines.map((l) => {
const n = counted[l.key] ?? 0;
const on = !!cur && l.key === cur.key;
return (
<MRow key={l.key} active={on} onClick={() => { setActiveKey(l.key); setTyping(false); }}
mark={n === l.expected ? "ok" : n > 0 ? "ink" : "mute"}
title={`${lineTitle(l)} ${l.size}`}
sub={l.where || undefined}
right={`${n}/${l.expected}`} />
);
})}
<div style={{ marginTop: 14 }}>
<MButton label="Type a count" onClick={() => { setHands(false); setTyping((t) => !t); }} disabled={!cur} />
</div>
</MBody>
<MSplit>
<MAction label="Undo" flex={1} tone="ink" onClick={() => cur && bump(cur.key, -1)} disabled={!cur || curN <= 0} />
<MAction label="Scan" flex={2} glyph="scan" glyphAt="right" onClick={() => { setHands(false); setTyping(false); setSingle(true); }} />
</MSplit>
{single && (
<MScan title="Scan a garment" onHit={(raw) => { onCode(raw); setSingle(false); }} onClose={() => setSingle(false)} />
)}
{hands && handsMode === "overlay" && (
<MScan
title="Hands-free"
live
running
log={log}
debounceMs={DEBOUNCE_MS}
onHit={onCode}
onClose={() => setHands(false)}
figure={cur ? <div style={{ background: INK, color: GROUND, padding: "12px 16px 0" }}>{panelInner}</div> : undefined}
control={<div style={{ background: INK, padding: "0 16px calc(12px + env(safe-area-inset-bottom, 0px))" }}><HandsFree on onToggle={() => setHands(false)} /></div>}
/>
)}
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
/* Check the gaps: the lines that don't match, each with a reason, then the commit.
A gap at or over the facility's threshold (settings.varianceReason, enforced again by
stocktake.apply) must carry a reason before the bar will commit. The reason chosen is stored
on the stocktake line. */
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSnap } from "@/lib/client";
import { UNPLACED } from "@/lib/compute";
import { clearCount, readCount, writeCount } from "@/lib/opencount";
import { INK, MBar, MBody, MEmpty, MError, MKick, MLine, MPill, MReasonChips, MRow, MRule, MSection, MTop } from "@/components/m";
import { OVER_REASONS, SHORT_REASONS, lineTitle, signed, useCountLines } from "@/components/m/count/lines";
const chip: React.CSSProperties = {
minHeight: 44, minWidth: 48, padding: "0 12px", border: "2px solid " + INK, background: "transparent", color: INK,
fontFamily: "inherit", fontSize: 14, fontWeight: 700, cursor: "pointer", borderRadius: 0, flex: "none",
};
export default function MVariance() {
const { s, mutate, busy } = useSnap();
const router = useRouter();
const locationId = String(useParams().id || "");
const { lines, locName } = useCountLines(locationId);
const [counted, setCounted] = useState<Record<string, number> | null>(null);
const [reason, setReason] = useState<Record<string, string>>({});
const [err, setErr] = useState("");
const me = s.session.userId;
useEffect(() => {
setCounted(readCount(me, locationId)?.n ?? {});
}, [me, locationId]);
const gate = Math.max(1, s.settings.varianceReason);
const gaps = useMemo(() => (counted ? lines.filter((l) => (counted[l.key] ?? 0) !== l.expected) : []), [counted, lines]);
const matches = useMemo(() => (counted ? lines.filter((l) => (counted[l.key] ?? 0) === l.expected) : []), [counted, lines]);
const missing = gaps.filter((l) => Math.abs((counted?.[l.key] ?? 0) - l.expected) >= gate && !reason[l.key]);
const ready = missing.length === 0;
const recount = useCallback((key: string) => {
if (!counted) return;
writeCount(me, locationId, { ...counted, [key]: 0 });
setReason((r) => { const n = { ...r }; delete n[key]; return n; });
router.push(`/m/count/${locationId}?line=${encodeURIComponent(key)}`);
}, [counted, me, locationId, router]);
const commit = useCallback(async () => {
if (!counted || !ready || busy) return;
const payload = lines.map((l) => {
const n = counted[l.key] ?? 0;
return { itemId: l.itemId, si: l.si, counted: n, reason: n !== l.expected ? reason[l.key] || "" : "" };
});
const r = await mutate("stocktake.apply", { lines: payload, mode: "shelf", locationId: locationId === UNPLACED ? "" : locationId });
if (!r.ok) { setErr(r.error); return; }
clearCount(me, locationId);
router.replace(`/m?flash=counted&loc=${encodeURIComponent(locName)}&gaps=${gaps.length}`);
}, [counted, ready, busy, lines, reason, mutate, locationId, me, router, locName, gaps.length]);
if (!counted) return (<><MTop title="Check the gaps" back /><MRule /><MBody pad /></>);
return (
<>
<MTop title="Check the gaps" back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
<MKick>{locName}</MKick>
<h2 style={{ fontSize: 26, fontWeight: 900, margin: "2px 0 0", lineHeight: 1.15 }}>
{gaps.length === 0 ? "Everything matches" : `${gaps.length} gap${gaps.length === 1 ? "" : "s"}`}
</h2>
{lines.length === 0 && <MEmpty title="Nothing on this shelf" />}
{gaps.map((l) => {
const n = counted[l.key] ?? 0;
const d = n - l.expected;
const name = `${lineTitle(l)} ${l.size}`;
return (
<div key={l.key} style={{ marginTop: 10 }}>
<MLine title={name} flag={`${n} counted, ${l.expected} expected`}
right={
<>
<MPill tone="accent" mono>{signed(d)}</MPill>
<button type="button" style={chip} onClick={() => recount(l.key)} aria-label={`Recount ${name}`}>Recount</button>
</>
}>
<MReasonChips reasons={d > 0 ? OVER_REASONS : SHORT_REASONS} value={reason[l.key] || null} label={`Reason for ${name}`}
onPick={(r) => setReason((x) => { const o = { ...x }; if (r) o[l.key] = r; else delete o[l.key]; return o; })} />
</MLine>
</div>
);
})}
{matches.length > 0 && (
<div style={{ marginTop: 22 }}>
<MSection label="Match" right={matches.length} />
{matches.map((l) => (
<MRow key={l.key} dense mark="ok" title={`${lineTitle(l)} ${l.size}`} right={counted[l.key] ?? 0} />
))}
</div>
)}
</MBody>
<MBar label={busy ? "Committing…" : "Commit count"}
small={ready ? `${lines.length} line${lines.length === 1 ? "" : "s"}` : "reason each gap"}
onClick={commit} disabled={!ready || busy || lines.length === 0}
offReason={!ready ? "Pick a reason for each gap" : undefined} />
</>
);
}
+1
View File
@@ -0,0 +1 @@
export { default } from "@/components/m/stock/CountList";
+27
View File
@@ -0,0 +1,27 @@
"use client";
/* The Done screen as its own history entry. A finished hand-over or delivery router.replace()s its
form here (components/SignFlow showDone), so Back skips the spent form, a router refresh re-renders
this screen rather than the form's parent, and a reload still shows what was just recorded. */
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { DoneScreen, readDone, type DoneProps } from "@/components/SignFlow";
import { MBody, MKick, MRule, MTop } from "@/components/m";
export default function DonePage() {
const router = useRouter();
const [done, setDone] = useState<DoneProps | null>(null);
useEffect(() => {
const d = readDone();
if (d) setDone(d);
else router.replace("/m");
}, [router]);
if (done) return <DoneScreen {...done} />;
return (
<>
<MTop title="Done" />
<MRule />
<MBody pad><MKick>Loading</MKick></MBody>
</>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
/* The person's record opens on its Issue segment. */
export default async function IssueTo({ params }: { params: Promise<{ staffId: string }> }) {
const { staffId } = await params;
redirect(`/m/person/${encodeURIComponent(staffId)}`);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/* Issuing starts from a person now: the People tab, or a scanned badge. */
export default function IssuePicker() {
redirect("/m/people");
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/* Reprinting a label is Print a label on the stock line's own page. */
export default function Label() {
redirect("/m/stock?seg=all");
}
+28
View File
@@ -0,0 +1,28 @@
import { redirect } from "next/navigation";
import { currentUser } from "@/lib/session";
import { buildSnapshot } from "@/lib/snapshot";
import { SnapshotProvider } from "@/lib/client";
import { MToastProvider } from "@/components/m";
import { MBasketProvider } from "@/components/MBasket";
export const dynamic = "force-dynamic";
/* Everything that needs a signed-in coordinator. Sending them to /m/login rather than /auth keeps
them in the app's own world: /auth is the website's two-pane sign-in, which is a jarring thing
to meet on a phone halfway through opening an app.
The toast and the in-progress basket sit inside the snapshot, and neither renders a wrapper
element, so every screen stays a direct child of .tcx-app (the native scan transparency relies
on that). */
export default async function MobileAppLayout({ children }: { children: React.ReactNode }) {
const user = await currentUser();
if (!user) redirect("/m/login");
const snap = await buildSnapshot(user);
return (
<SnapshotProvider snap={snap}>
<MToastProvider>
<MBasketProvider>{children}</MBasketProvider>
</MToastProvider>
</SnapshotProvider>
);
}
+98
View File
@@ -0,0 +1,98 @@
"use client";
/* One stock line: a garment in one size. On hand against par, where it lives, what is coming, and the
three things done about it: print a label, count its shelf, put it on the draft order. */
import { useCallback, useState } from "react";
import { useParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { bcBound, key, label, locMap, locTrail, money, onOrderText, onhand, reorderAt } from "@/lib/compute";
import { INK, MBody, MButton, MEmpty, MKick, MONO, MPill, MRow, MRule, MTop, useToast } from "@/components/m";
import PrintSheet from "@/components/m/stock/PrintSheet";
import { heldByStaff, lastCountedText } from "@/components/m/stock/stockdata";
const dt: React.CSSProperties = { margin: 0, padding: "11px 14px 11px 0", borderBottom: "1px solid var(--color-divider)", fontSize: 12, fontWeight: 800, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-neutral-600)" };
const dd: React.CSSProperties = { margin: 0, padding: "11px 0", borderBottom: "1px solid var(--color-divider)", textAlign: "right", fontWeight: 700 };
export default function MLinePage() {
const params = useParams<{ itemId: string; si: string }>();
const { s, isAdmin, mutate, busy } = useSnap();
const { L } = useDerived();
const toast = useToast();
const [printing, setPrinting] = useState(false);
// Stable, because MSheet re-runs its focus handling whenever onClose changes.
const closePrint = useCallback(() => setPrinting(false), []);
const itemId = decodeURIComponent(String(params.itemId || ""));
const si = /^\d+$/.test(String(params.si || "")) ? Number(params.si) : -1;
const it = s.catalog.find((x) => x.id === itemId);
if (!it || it.archived || si < 0 || si >= it.sizes.length) {
return (
<>
<MTop title="Stock line" back />
<MRule />
<MBody pad><MEmpty title="No such line" sub="It may have been archived." /></MBody>
</>
);
}
const k = key(it.id, si);
const size = String(it.sizes[si]);
const oh = onhand(s, L, k), par = reorderAt(s, k);
const code = bcBound(s, it, si);
const locs = locMap(s);
const placedId = s.placed[k];
const placed = placedId ? locs[placedId] : undefined;
const shelf = placed ? (locTrail(locs, placed.id, 0) || placed.name) : "";
const onOrder = onOrderText(s, it.id, si);
const short = Math.max(0, par - oh);
const addToDraft = async () => {
const r = await mutate<{ added: number; qty: number }>("stock.orderLine", { itemId: it.id, si });
if (!r.ok) { toast(r.error); return; }
toast(r.result.added > 0 ? `Added ${r.result.added} to the draft order` : "Already on the draft order");
};
return (
<>
<MTop title={`${it.item} ${size}`} back />
<MRule />
<MBody pad>
<MKick mono>{code || (it.sku ? `SKU ${it.sku}` : "No barcode")}</MKick>
<h2 style={{ fontSize: 24, fontWeight: 900, margin: "2px 0 0", lineHeight: 1.1 }}>{label(it)} · {size}</h2>
<div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", gap: 12, marginTop: 14, borderBottom: "2px solid " + INK, paddingBottom: 12 }}>
<div style={{ fontSize: 64, fontWeight: 900, lineHeight: 0.95, letterSpacing: "-0.03em", fontVariantNumeric: "tabular-nums" }}>
{oh}<small style={{ fontSize: 22, fontWeight: 700, color: "var(--color-neutral-600)", letterSpacing: 0 }}> / {par} par</small>
</div>
{short > 0
? <MPill tone={oh <= 0 ? "accent" : "ink"}>{short} short</MPill>
: <MPill tone="ok">At par</MPill>}
</div>
<dl style={{ display: "grid", gridTemplateColumns: "auto 1fr", margin: "8px 0 0" }}>
<dt style={dt}>Shelf</dt><dd style={dd}>{shelf || "Not on a shelf"}</dd>
<dt style={dt}>On order</dt><dd style={dd}>{onOrder || "None"}</dd>
<dt style={dt}>Last counted</dt><dd style={dd}>{lastCountedText(s, it.id, si)}</dd>
<dt style={dt}>Held by staff</dt><dd style={{ ...dd, fontFamily: MONO }}>{heldByStaff(s, it.id, si)}</dd>
<dt style={dt}>Unit cost</dt><dd style={{ ...dd, fontFamily: MONO }}>{money(it.cost)}</dd>
</dl>
<div style={{ marginTop: 4 }}>
<MButton tone="ink" icon="print" label="Print a label" disabled={!code} onClick={() => setPrinting(true)} />
{!code && <div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 6 }}>No barcode bound</div>}
{!code && isAdmin && <MButton label="Bind a barcode" href={`/m/catalogue/${encodeURIComponent(it.id)}`} />}
{placed && <MButton label={`Count ${shelf}`} href={`/m/count/${encodeURIComponent(placed.id)}?line=${encodeURIComponent(k)}`} />}
<MButton label={busy ? "Adding…" : "Add to the draft order"} onClick={addToDraft} disabled={busy} />
</div>
{isAdmin && (
<div style={{ marginTop: 14 }}>
<MRow chev href={`/m/catalogue/${encodeURIComponent(it.id)}`} title="Product card" />
</div>
)}
</MBody>
{code && <PrintSheet open={printing} onClose={closePrint} code={code} title={`${label(it)} ${size}`} />}
</>
);
}
+54
View File
@@ -0,0 +1,54 @@
/* What a tap looks like before the server answers, for the counter app.
*
* The twin of app/my/(app)/loading.tsx, and here for the same reason: every screen under /m is
* rendered from its own server query, App Router keeps the previous screen fully painted until that
* query comes back, and on linen-room wifi that is seconds in which nothing acknowledges the tap.
* People tap again — and on this app the second tap can land on a different row.
*
* It draws the app's own chrome (the 56px ink bar and the 4px accent rule, the shape MTop and MRule
* make) so the change reads as "loading" rather than "gone", and deliberately not the tab bar: the
* nav belongs to the four screens that draw it, and painting one here would flash it into existence
* on the way to a detail screen that has none. The bar carries no screen title for the same reason
* — this one fallback covers every route in the group, so any title would be wrong somewhere.
*/
const INK = "#201e1d";
const GROUND = "#f3f2f2";
/** A grey block standing in for a line of text. Sized in the same 2px system as everything else. */
function Bar({ w, h = 16 }: { w: string; h?: number }) {
return <div style={{ width: w, height: h, background: "var(--color-neutral-200)" }} />;
}
export default function CounterLoading() {
return (
<>
<header className="tcx-topbar" style={{
height: 56, flex: "0 0 56px", background: INK, color: GROUND, display: "flex", alignItems: "center",
paddingLeft: 16, paddingRight: 16,
backgroundImage: "linear-gradient(to bottom, rgba(243,242,242,0.16) 0 1px, transparent 1px)",
backgroundPosition: "0 env(safe-area-inset-top, 0px)", backgroundRepeat: "no-repeat", backgroundSize: "100% 1px",
}}>
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.06em", textTransform: "uppercase" }}>
One moment
</span>
</header>
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
<div style={{ flex: 1, overflow: "hidden", background: GROUND }} aria-busy="true">
{/* Announced once, quietly. The blocks below are decoration and say nothing. */}
<div role="status" style={{ padding: "20px 16px 0", fontSize: 13, color: "var(--color-neutral-600)" }}>Loading</div>
<div style={{ padding: "16px 16px 0", display: "grid", gap: 10 }} aria-hidden="true">
<Bar w="60%" h={22} />
<Bar w="40%" />
</div>
<div style={{ marginTop: 24, display: "grid", gap: 2 }} aria-hidden="true">
{[0, 1, 2, 3].map((i) => (
<div key={i} style={{ background: "#fff", padding: "18px 16px", display: "grid", gap: 8 }}>
<Bar w="55%" h={18} />
<Bar w="35%" h={12} />
</div>
))}
</div>
</div>
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/* More's rows moved: Settings is the gear on Today, the rest live in Stock and Work. */
export default function More() {
redirect("/m");
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
/* Today: what is waiting, what this counter has done today, and what just happened. */
import { Suspense } from "react";
import { MBody, MDay, MEmpty, MKick, MRow, MRule, MSection, MTabs, MTodo, MTop, MTopAction } from "@/components/m";
import TodayBanner from "@/components/m/today/TodayBanner";
import { useToday } from "@/components/m/today/useToday";
export default function MToday() {
const t = useToday();
return (
<>
<MTop title="Today" right={<MTopAction icon="gear" label="Settings" ariaLabel="Settings" href="/m/settings" />} />
<MRule />
<MBody pad>
<Suspense fallback={null}><TodayBanner /></Suspense>
<MKick>{t.kicker}</MKick>
<MSection label="To do" />
{t.todo.length === 0
? <MEmpty title="Nothing waiting" />
: t.todo.map((r) => <MTodo key={r.key} n={r.n} accent={r.accent} title={r.title} sub={r.sub} href={r.href} />)}
<MSection label="Your day" />
<MDay figs={[{ n: t.day.issued, label: "Issued" }, { n: t.day.back, label: "Handed back" }, { n: t.day.counted, label: "Shelves counted" }]} />
<MSection label="Recent" />
{t.recent.length === 0
? <MEmpty title="Nothing yet today" />
: t.recent.map((r) => <MRow key={r.key} mark="mute" title={r.title} sub={r.sub} right={r.right} href={r.href} />)}
</MBody>
<MTabs active="today" />
</>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
/* The People tab: the staff register, served-recently first, each row showing how near they are to
the sets one person holds (capCheck, the same check the counter uses). */
import { useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useSnap } from "@/lib/client";
import { capCheck, staffName } from "@/lib/compute";
import { MBody, MEmpty, MRow, MRule, MSearch, MSection, MTabs, MTop, MTopCount } from "@/components/m";
export default function People() {
const { s } = useSnap();
const sp = useSearchParams();
const [q, setQ] = useState(() => (sp.get("q") || "").slice(0, 80));
const active = useMemo(() => s.staff.filter((x) => !x.inactive), [s]);
const list = useMemo(() => {
const needle = q.trim().toLowerCase();
if (!needle) {
const seen: Record<string, string> = {};
for (const i of s.issues) if (i.date > (seen[i.staffId] || "")) seen[i.staffId] = i.date;
return [...active].sort((a, b) => (seen[b.id] || "").localeCompare(seen[a.id] || "") || staffName(a).localeCompare(staffName(b))).slice(0, 12);
}
return s.staff
.filter((x) => `${x.first} ${x.last} ${x.num} ${x.dept} ${x.group}`.toLowerCase().includes(needle))
.sort((a, b) => Number(a.inactive) - Number(b.inactive) || staffName(a).localeCompare(staffName(b)))
.slice(0, 40);
}, [s, q, active]);
return (
<>
<MTop title="People" right={<MTopCount>{active.length}</MTopCount>} />
<MRule />
<MBody pad>
<MSearch value={q} onChange={setQ} placeholder="Name or staff number" label="Search the staff register" scanHref="/m/scan" />
{s.staff.length === 0 ? (
<MEmpty title="No staff yet" sub="Staff are added in the portal." />
) : (
<>
<MSection label={q.trim() ? "Matches" : "Served recently"} right={list.length} />
{list.map((st) => {
const cap = capCheck(s, st);
const most = Math.max(cap.tops, cap.pants);
return (
<MRow key={st.id} href={`/m/person/${st.id}`} title={staffName(st)}
sub={[st.group, st.num, st.dept].filter((x) => x && x.trim()).join(" · ")}
right={`${most}/${cap.cap}`} mark={st.inactive ? "mute" : most >= cap.cap ? "accent" : "ink"} />
);
})}
{q.trim() && list.length === 0 && <MEmpty title={`No one matches “${q.trim()}`} sub="Check the spelling, or scan their badge." />}
</>
)}
</MBody>
<MTabs active="people" />
</>
);
}
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
/* A size exchange is Swap size on a Hand back line. */
export default async function ExchangeFor({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
redirect(`/m/person/${encodeURIComponent(id)}?tab=back`);
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
/* The person screen is the issue screen: who they are, how near the six sets they hold, and the
* Issue | Hand back | History segments. Each segment draws its own docked bar through setBar; a
* finished hand back swaps the whole screen for the Done screen in place. */
import { useCallback, useRef, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import { useSnap } from "@/lib/client";
import { approvalRemaining, capCheck, isNursing, staffName } from "@/lib/compute";
import { MBody, MEmpty, MError, MHead, MHeadRow, MMeterPair, MPill, MRule, MSeg, MTop } from "@/components/m";
import { useBasket } from "@/components/MBasket";
import { DoneScreen, type DoneProps } from "@/components/SignFlow";
import HandBackTab from "@/components/person/HandBackTab";
import IssueTab from "@/components/m/issue/IssueTab";
import HistoryTab from "@/components/m/issue/HistoryTab";
import { personMeta, plural } from "@/components/m/issue/meta";
type Tab = "issue" | "back" | "history";
const TABS: { key: Tab; label: string }[] = [
{ key: "issue", label: "Issue" },
{ key: "back", label: "Hand back" },
{ key: "history", label: "History" },
];
const asTab = (v: string | null): Tab => (v === "back" || v === "history" ? v : "issue");
export default function MPersonPage() {
const { s } = useSnap();
const id = String(useParams().id || "");
const sp = useSearchParams();
const basket = useBasket();
const st = s.staff.find((x) => x.id === id);
const [tab, setTab] = useState<Tab>(() => asTab(sp.get("tab")));
const [bar, setBarNode] = useState<React.ReactNode>(null);
const [err, setErr] = useState("");
const [done, setDone] = useState<DoneProps | null>(null);
const top = useRef<HTMLSpanElement | null>(null);
const setBar = useCallback((b: React.ReactNode) => setBarNode(b), []);
const onError = useCallback((m: string) => setErr(m), []);
const onDone = useCallback((d: DoneProps) => { setErr(""); setDone(d); }, []);
if (done) return <DoneScreen {...done} />;
if (!st) {
return (
<>
<MTop title="Person" back />
<MRule />
<MBody pad><MEmpty title="No such staff member" /></MBody>
</>
);
}
const pickTab = (k: Tab) => {
if (k === tab) return;
setErr("");
setTab(k);
try { window.history.replaceState(null, "", `/m/person/${encodeURIComponent(id)}${k === "issue" ? "" : `?tab=${k}`}`); } catch { /* not fatal */ }
const body = top.current?.parentElement;
if (body) body.scrollTop = 0;
};
const lines = basket.issue(id);
const cap = capCheck(s, st, lines);
const left = approvalRemaining(s, st.id);
const props = { staffId: st.id, setBar, onDone, onError };
return (
<>
<MTop title={staffName(st)} back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
<span ref={top} aria-hidden="true" style={{ display: "block", height: 0 }} />
<MHead name={staffName(st)} meta={personMeta(s, st)}>
{tab === "issue" && (
<>
<MMeterPair items={[
{ label: "Tops held", held: cap.tops, adding: cap.addTops, cap: cap.cap },
{ label: "Pants held", held: cap.pants, adding: cap.addPants, cap: cap.cap },
]} />
{isNursing(s, st) && <MHeadRow label="Manager approval" value={`${plural(left, "set")} left`} />}
</>
)}
</MHead>
{st.inactive && <div style={{ margin: "-4px 0 10px" }}><MPill tone="accent">Inactive</MPill></div>}
<MSeg label="Record" value={tab} options={TABS} onPick={pickTab} />
{tab === "issue" ? <IssueTab {...props} /> : tab === "back" ? <HandBackTab {...props} /> : <HistoryTab {...props} />}
</MBody>
{bar}
</>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
/* Returns are the Hand back segment of the person's record. */
export default async function ReturnFrom({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
redirect(`/m/person/${encodeURIComponent(id)}?tab=back`);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
/* Sign (issue): what is being handed over, the manager approval deduction, their signature and the
* slip switch. issue.create records the lines, the per-line reasons, the approval deduction and the
* signed slip in one locked write; SignFlow then shows the Done screen in place. */
import { useState } from "react";
import { useParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { approvalRemaining, isNursing, isPantItem, isTopItem, issueLineFlags, label, staffName } from "@/lib/compute";
import { MBody, MButton, MEmpty, MHead, MRow, MRule, MSection, MStepper, MTop } from "@/components/m";
import { useBasket, type IssueLine } from "@/components/MBasket";
import SignFlow from "@/components/SignFlow";
import { personMeta, plural } from "@/components/m/issue/meta";
export default function SignIssue() {
const { s, mutate } = useSnap();
const { byId } = useDerived();
const id = String(useParams().id || "");
const basket = useBasket();
const st = s.staff.find((x) => x.id === id);
// Once issued the basket is cleared; the lines are kept here so the screen does not fall back to
// "nothing to issue" in the moment before the Done screen replaces it.
const [frozen, setFrozen] = useState<IssueLine[] | null>(null);
const lines = frozen ?? basket.issue(id);
if (!st || !lines.length) {
return (
<>
<MTop title="Sign" back />
<MRule />
<MBody pad>
<MEmpty title={st ? "Nothing to issue" : "No such staff member"} />
{st && <MButton label="Back to their record" href={`/m/person/${encodeURIComponent(st.id)}`} />}
</MBody>
</>
);
}
const n = lines.reduce((t, l) => t + l.qty, 0);
const tops = lines.reduce((t, l) => t + (isTopItem(byId[l.itemId]) ? l.qty : 0), 0);
const pants = lines.reduce((t, l) => t + (isPantItem(byId[l.itemId]) ? l.qty : 0), 0);
const sets = Math.max(tops, pants);
const left = approvalRemaining(s, st.id);
const approval = isNursing(s, st) && left > 0;
const most = Math.min(left, sets);
const chosen = basket.deduct(id);
const deduct = Math.max(0, Math.min(most, chosen ?? most));
const extra = approval ? (
<>
<MSection label="Manager approval" right={`${plural(left, "set")} left`} />
<MRow title="Take off the approval" sub={`This issue is ${plural(sets, "set")}`}
right={<MStepper label="sets off the approval" n={deduct} min={0} max={most} onChange={(v) => basket.setDeduct(id, v)} />} />
</>
) : undefined;
const commit = async ({ sigId, slip }: { sigId: string; slip: boolean }) => {
const flags = issueLineFlags(s, st, lines);
const r = await mutate<{ slipId: string | null }>("issue.create", {
staffId: st.id,
lines: lines.map((l, i) => ({ itemId: l.itemId, si: l.si, qty: l.qty, src: "stock", reason: flags[i] ? l.reason || "" : "" })),
override: flags.some(Boolean),
lineReasons: true,
apDeduct: approval ? deduct : 0,
sigId,
slip,
});
if (!r.ok) return { ok: false as const, error: r.error };
const sent = lines;
setFrozen(sent);
basket.clear("issue", st.id);
return {
ok: true as const,
done: {
head: `${plural(n, "item")} issued`,
sub: `${staffName(st)} · ${slip ? "slip sent to their staff app" : "signed"}`,
shelfKeys: sent.map((l) => l.key),
next: "scan" as const,
},
};
};
return (
<SignFlow
kind="issue"
head={<MHead name={staffName(st)} meta={personMeta(s, st)} />}
lines={lines.map((l) => ({ key: l.key, name: `${label(byId[l.itemId])} ${String(byId[l.itemId]?.sizes[l.si] ?? l.si)}`, qty: l.qty }))}
signerName={staffName(st)}
extra={extra}
slip={{ available: !!st.selfEmail }}
barLabel={`Issue ${plural(n, "item")}`}
commit={commit}
/>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/* The pickup call list is Work Pickups. */
export default function Pickups() {
redirect("/m/work?seg=pickups");
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
/* Receive a delivery: tick each line as it is unpacked, step a line down when less came. Receiving
closes the order; anything short goes onto a back order raised by order.receive. */
import { useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { key, staffName } from "@/lib/compute";
import { plural } from "@/lib/today";
import { DoneScreen, useShowDone, type DoneProps } from "@/components/SignFlow";
import { MBar, MBody, MButton, MEmpty, MError, MField, MKick, MPickRow, MRule, MSection, MStepper, MTop, inputStyle } from "@/components/m";
import { RECEIVABLE, garmentSize, locMap, orderWhen, outstandingLines, segment, shelfOf, type OutLine } from "@/components/m/work/util";
export default function ReceiveOrder() {
const id = segment(useParams<{ id: string }>().id);
const { s, mutate } = useSnap();
const { byId, staffById } = useDerived();
const order = s.orders.find((o) => o.id === id);
const lines = useMemo(() => (order && RECEIVABLE.includes(order.status) ? outstandingLines(order, byId) : []), [order, byId]);
const locs = useMemo(() => locMap(s), [s]);
const [tick, setTick] = useState<Record<string, boolean>>({});
const [qty, setQty] = useState<Record<string, number>>({});
const [invoice, setInvoice] = useState(() => order?.invoice || "");
const [err, setErr] = useState("");
const [saving, setSaving] = useState(false);
const [done, setDone] = useState<DoneProps | null>(null);
const showDone = useShowDone();
if (done) return <DoneScreen {...done} />;
const q = (l: OutLine) => qty[l.id] ?? l.outstanding;
const ticked = lines.filter((l) => tick[l.id]);
const n = ticked.reduce((t, l) => t + q(l), 0);
const all = lines.length > 0 && ticked.length === lines.length;
const receive = async () => {
if (!order || saving) return;
setSaving(true);
setErr("");
const r = await mutate("order.receive", {
id: order.id, invoice: invoice.trim(),
lines: lines.map((l) => ({ lineId: l.id, itemId: l.itemId, size: l.size, arrived: q(l), dest: order.staffId ? "pickup" : "shelf" })),
});
setSaving(false);
if (!r.ok) { setErr(r.error); return; }
const short = lines.some((l) => q(l) < l.outstanding);
const d: DoneProps = {
head: `${plural(n, "item")} received`,
sub: `${order.code} · ${order.supplier || "Supplier"} · order closed${short ? " · back order raised" : ""}`,
shelfKeys: order.staffId ? [] : lines.filter((l) => q(l) > 0 && l.si >= 0).map((l) => key(l.itemId, l.si)),
next: "work",
};
setDone(d);
showDone(d);
};
const forName = order?.staffId ? staffName(staffById[order.staffId]) : "";
return (
<>
<MTop title={order?.code || "Receive"} back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
{!order || !lines.length ? (
<>
<MEmpty title="Nothing outstanding" />
<MButton label="Back to Work" href="/m/work?seg=in" />
</>
) : (
<>
<MKick>{`${order.supplier || "Supplier"} · ${orderWhen(s, order)}`}</MKick>
<MSection label="Tick what came in" right={`${ticked.length} of ${lines.length}`} />
{lines.map((l) => {
const name = garmentSize(byId[l.itemId], "Garment", l.size);
const sub = forName ? `for ${forName}` : l.si >= 0 ? shelfOf(s, locs, l.itemId, l.si) : "";
return (
<MPickRow key={l.id} done={!!tick[l.id]} onToggle={() => setTick((x) => ({ ...x, [l.id]: !x[l.id] }))} title={name} sub={sub || undefined} tickLabel={`Came in ${name}`}>
<MStepper n={q(l)} min={0} max={l.outstanding} label={name} onChange={(v) => setQty((x) => ({ ...x, [l.id]: v }))} />
</MPickRow>
);
})}
<MButton label="Tick all" onClick={() => setTick(Object.fromEntries(lines.map((l) => [l.id, true])))} />
<MField label="Invoice number">
<input value={invoice} onChange={(e) => setInvoice(e.target.value)} autoComplete="off" maxLength={80} style={inputStyle} />
</MField>
</>
)}
</MBody>
{order && lines.length > 0 && (
<MBar label={saving ? "Recording…" : `Receive ${plural(n, "item")}`} disabled={saving || !all || n === 0}
offReason={saving ? undefined : !all ? "Tick each line" : "Nothing arrived"} onClick={receive} />
)}
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/* Deliveries to receive are Work In; each opens /m/receive/[id]. */
export default function Receive() {
redirect("/m/work?seg=in");
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
/* Draft order: what fell below par, at quantities that bring each line back up. Raising it makes a
draft on Ordering; nothing reaches a supplier until someone approves it there. */
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { flaggedNeeds, label, onhand, reorderAt, touched } from "@/lib/compute";
import { MBar, MBody, MButton, MDone, MEmpty, MError, MRow, MRule, MSection, MStepper, MTop, MTopCount } from "@/components/m";
export default function MReorder() {
const { s, mutate, busy } = useSnap();
const { L, byId, variants } = useDerived();
const [qty, setQty] = useState<Record<string, number>>({});
const [err, setErr] = useState("");
const [done, setDone] = useState<string | null>(null);
const needs = useMemo(() => flaggedNeeds(s, L, byId).map((n) => {
const k = `${n.itemId}:${n.si}`;
return { ...n, key: k, name: `${label(byId[n.itemId])} ${n.size}`, oh: onhand(s, L, k), par: reorderAt(s, k) };
}), [s, L, byId]);
// Stock's "short" counts every line at or under par; flaggedNeeds drops the ones open orders cover.
const belowPar = useMemo(
() => variants.filter((v) => touched(s, L, v.key) && onhand(s, L, v.key) <= reorderAt(s, v.key)).length,
[s, L, variants],
);
const covered = Math.max(0, belowPar - needs.length);
const q = (k: string, fallback: number) => qty[k] ?? fallback;
const total = needs.reduce((t, n) => t + q(n.key, n.qty), 0);
const suppliers = [...new Set(needs.map((n) => n.supplier))];
const raise = async () => {
const bySup: Record<string, typeof needs> = {};
for (const n of needs) if (q(n.key, n.qty) > 0) (bySup[n.supplier] ||= []).push(n);
const codes: string[] = [];
for (const sup of Object.keys(bySup)) {
const r = await mutate<{ code: string }>("order.create", {
orderFor: "Stock", supplier: sup, replenish: false, notes: "Raised from a stocktake on the app",
lines: bySup[sup].map((n) => ({ itemId: n.itemId, size: n.size, qty: q(n.key, n.qty) })),
});
if (!r.ok) { setErr(r.error); return; }
codes.push(r.result.code);
}
setDone(codes.join(" · "));
};
if (done) return (
<>
<MTop title="Done" />
<MRule />
<MBody pad>
<MDone head={`Draft ${done} raised`} sub="Waiting on Ordering" />
<MButton label="Back to Stock" href="/m/stock" />
</MBody>
</>
);
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
return (
<>
<MTop title="Draft order" back right={<MTopCount>{plural(needs.length, "line")}</MTopCount>} />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
{needs.length === 0 ? (
<MEmpty title={belowPar ? "Already on order" : "Nothing to reorder"}
sub={belowPar ? `${plural(belowPar, "line")} short, all on open orders` : "Every line is above par"} />
) : (
<>
<MSection label="To order" right={suppliers.length === 1 ? suppliers[0] : plural(suppliers.length, "supplier")} />
{needs.map((n) => (
<MRow key={n.key} mark={n.oh <= 0 ? "accent" : "ink"} title={n.name}
sub={`${n.oh}/${n.par} · ${n.supplier}`}
right={<MStepper n={q(n.key, n.qty)} onChange={(v) => setQty((x) => ({ ...x, [n.key]: v }))} label={n.name} />} />
))}
</>
)}
{covered > 0 && needs.length > 0 && (
<>
<MSection label="Already on order" right={covered} />
<MRow mark="mute" chev href="/m/stock?seg=order" title="On order" right={covered} />
</>
)}
</MBody>
{needs.length > 0 && (
<MBar label={busy ? "Raising…" : "Raise the draft"} small={plural(total, "item")} onClick={raise}
disabled={busy || total === 0} offReason={total === 0 ? "Set a quantity on at least one line" : undefined} />
)}
</>
);
}
+135
View File
@@ -0,0 +1,135 @@
"use client";
/* Pick an approved staff request: tick or scan each garment in the bag, then hand it over. The first
pick moves the request to "picking" through request.pick, so the desktop queue shows it as being
picked. Ticks live in the basket (this phone only) until the hand-over is signed. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { wasSpent } from "@/components/SignFlow";
import { useDerived, useSnap } from "@/lib/client";
import { bcParse, key, onhand } from "@/lib/compute";
import { scanReject } from "@/lib/feedback";
import { relativeDay } from "@/lib/today";
import { PICK_STATUSES } from "@/lib/workcount";
import { useRequests } from "@/components/requests/RequestList";
import { useBasket } from "@/components/MBasket";
import MScan from "@/components/MScan";
import {
MBar, MBody, MButton, MEmpty, MError, MHead, MKick, MPickRow, MRow, MRule, MSection, MTop, useScanFlash, useToast,
} from "@/components/m";
import { garmentSize, locMap, personMeta, segment, shelfOf } from "@/components/m/work/util";
export default function PickRequest() {
const id = segment(useParams<{ id: string }>().id);
const { s, mutate } = useSnap();
const { L, byId, staffById } = useDerived();
const { data, error, reload } = useRequests();
const basket = useBasket();
const toast = useToast();
const flash = useScanFlash();
const [err, setErr] = useState("");
const [scanning, setScanning] = useState(false);
const pickFired = useRef(false);
const locs = useMemo(() => locMap(s), [s]);
const r = data?.requests.find((x) => x.id === id);
const lines = useMemo(() => r?.bag ?? [], [r]);
const stored = basket.picked(id);
const router = useRouter();
const gone = !!data && (!r || !PICK_STATUSES.has(r.status) || !lines.length);
const spent = gone && wasSpent(`/m/request/${id}`);
// Back from this request's own Done screen: the list is spent, so carry on to the queue.
useEffect(() => { if (spent) router.replace("/m/work?seg=picks"); }, [spent, router]);
// A ready bag was packed at the counter already: every line starts ticked.
const got = useCallback((lineId: string, q: number) => {
const v = stored[lineId];
if (v === undefined) return r?.status === "ready" ? q : 0;
return Math.max(0, Math.min(q, v));
}, [stored, r]);
const total = lines.reduce((t, l) => t + l.qty, 0);
const picked = lines.reduce((t, l) => t + got(l.id, l.qty), 0);
const setLine = (lineId: string, n: number) => {
const prev = stored;
basket.setPicked(id, { ...prev, [lineId]: n });
return () => basket.setPicked(id, prev);
};
/** The first garment picked off an approved request tells the queue it is being picked. */
const firstPick = async (undo: () => void) => {
if (!r || r.status !== "accepted" || pickFired.current) return;
pickFired.current = true;
const res = await mutate("request.pick", { id: r.id });
if (!res.ok) { pickFired.current = false; undo(); setErr(res.error); void reload(); return; }
void reload();
};
const toggle = (lineId: string, q: number) => {
const cur = got(lineId, q);
const undo = setLine(lineId, cur >= q ? 0 : q);
if (cur < q) void firstPick(undo);
};
const onScan = (raw: string) => {
setScanning(false);
const hit = bcParse(s, raw);
if (!hit) { scanReject(); toast("That code isnt a garment"); return; }
const it = byId[hit.itemId];
const name = garmentSize(it, "Garment", it?.sizes[hit.si] ?? hit.si);
const match = lines.filter((l) => l.itemId === hit.itemId && l.si === hit.si);
if (!match.length) { scanReject(); toast(`${name} isnt on this request`); return; }
const next = match.find((l) => got(l.id, l.qty) < l.qty);
if (!next) { toast("Everything is picked"); return; }
const n = got(next.id, next.qty) + 1;
flash("Garment", name, () => { const undo = setLine(next.id, n); void firstPick(undo); });
};
const shell = (body: React.ReactNode, bar?: React.ReactNode) => (
<>
<MTop title="Pick request" back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>{body}</MBody>
{bar}
</>
);
if (!data) {
return shell(error
? <><MError msg="Requests couldnt be loaded." /><MButton small label="Try again" onClick={() => void reload()} /></>
: <MKick>Loading</MKick>);
}
if (!r || !PICK_STATUSES.has(r.status) || !lines.length) {
if (spent) return shell(<MKick>Loading</MKick>);
return shell(<><MEmpty title="That request has moved on" /><MButton label="Back to Work" href="/m/work?seg=picks" /></>);
}
const st = staffById[r.staffId];
const when = relativeDay(r.decidedAt, s);
return (
<>
{shell(
<>
<MHead name={r.staffName} meta={personMeta(s, st, r.ward)} />
<MButton tone="ink" icon="scan" label="Scan to pick"
onClick={() => (picked >= total ? toast("Everything is picked") : setScanning(true))} />
<MSection label="Pick list" right={`${picked} of ${total}`} />
{lines.map((l, i) => {
const n = got(l.id, l.qty);
const onShelf = Math.max(0, onhand(s, L, key(l.itemId, l.si)));
const sub = [shelfOf(s, locs, l.itemId, l.si), `${onShelf} on shelf`, r.status === "ready" && i === 0 && r.collectCode ? `code ${r.collectCode}` : ""]
.filter(Boolean).join(" · ");
return (
<MPickRow key={l.id} done={n >= l.qty} onToggle={() => toggle(l.id, l.qty)}
title={garmentSize(byId[l.itemId], l.item, l.size)} sub={sub} right={`${n}/${l.qty}`} />
);
})}
<MSection label="Approved" />
<MRow mark="ok" title={r.managerName || "Manager"} sub={when ? `Approved ${when}` : "Approved"} />
</>,
<MBar label="Hand over" small={`${picked} of ${total} picked`} disabled={picked < total}
offReason="Pick every line first" href={`/m/request/${r.id}/sign`} />,
)}
{scanning && <MScan title="Scan to pick" onHit={onScan} onClose={() => setScanning(false)} />}
</>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
/* Sign for a picked request, then Done. A request still "accepted" is moved to "picking" first
(the queue only lets a picked request be collected); request.collected writes the issue rows, the
signature and, when asked, the slip to the wearer's staff app. */
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { key } from "@/lib/compute";
import { plural } from "@/lib/today";
import { PICK_STATUSES } from "@/lib/workcount";
import { useRequests, type RequestRow } from "@/components/requests/RequestList";
import { useBasket } from "@/components/MBasket";
import SignFlow from "@/components/SignFlow";
import { MBody, MButton, MEmpty, MError, MHead, MKick, MRule, MTop } from "@/components/m";
import { garmentSize, personMeta, segment } from "@/components/m/work/util";
export default function RequestSign() {
const id = segment(useParams<{ id: string }>().id);
const { s, mutate } = useSnap();
const { byId, staffById } = useDerived();
const { data, error, reload } = useRequests();
const basket = useBasket();
// Held once loaded: the hand-over refreshes the queue, and the Done screen must not turn into
// "moved on" when this request leaves it.
const [held, setHeld] = useState<{ r: RequestRow; ready: boolean } | null>(null);
const [seen, setSeen] = useState(false);
const stored = basket.picked(id);
useEffect(() => {
if (held || !data) return;
const r = data.requests.find((x) => x.id === id);
if (r && PICK_STATUSES.has(r.status) && r.bag.length) {
const ready = r.status === "ready" || r.bag.every((l) => (stored[l.id] ?? 0) >= l.qty);
setHeld({ r, ready });
}
setSeen(true);
}, [data, id, held, stored]);
const shell = (body: React.ReactNode) => (
<>
<MTop title="Sign" back />
<MRule />
<MBody pad>{body}</MBody>
</>
);
if (!held) {
if (!data && error) return shell(<><MError msg="Requests couldnt be loaded." /><MButton small label="Try again" onClick={() => void reload()} /></>);
if (!data || !seen) return shell(<MKick>Loading</MKick>);
return shell(<><MEmpty title="That request has moved on" /><MButton label="Back to Work" href="/m/work?seg=picks" /></>);
}
const { r } = held;
if (!held.ready) {
return shell(<><MEmpty title="Pick every line first" /><MButton label="Back to the pick list" href={`/m/request/${r.id}`} /></>);
}
const st = staffById[r.staffId];
const total = r.bag.reduce((t, l) => t + l.qty, 0);
return (
<SignFlow
kind="request"
head={<MHead name={r.staffName} meta={personMeta(s, st, r.ward)} />}
lines={r.bag.map((l) => ({ key: l.id, name: garmentSize(byId[l.itemId], l.item, l.size), qty: l.qty }))}
signerName={r.staffName}
slip={{ available: !!st?.selfEmail }}
barLabel={`Hand over ${plural(total, "item")}`}
spentHref={`/m/request/${r.id}`}
commit={async ({ sigId, slip }) => {
// Accepted cannot go straight to collected. If the pick screen already moved it, this
// refusal is expected and collected below answers for the real state.
if (r.status === "accepted") await mutate("request.pick", { id: r.id });
const c = await mutate("request.collected", { id: r.id, sigId, slip });
if (!c.ok) { void reload(); return { ok: false, error: c.error }; }
basket.clear("picked", r.id);
void reload();
return {
ok: true,
done: {
head: "Request handed over",
sub: `${r.staffName} · ${plural(total, "item")}${slip ? " · slip sent" : ""}`,
shelfKeys: r.bag.map((l) => key(l.itemId, l.si)),
next: "work",
},
};
}}
/>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
/* Sign a ward round: every bag waiting for that ward (lib/today roundSheet, the same set the Rounds
segment counted), one signature for the lot through pickup.deliverWard, then Done. */
import { useMemo, useRef, useState } from "react";
import { useParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { plural, roundSheet } from "@/lib/today";
import { takePhoto, uploadPhoto } from "@/lib/photo";
import SignFlow from "@/components/SignFlow";
import { MBody, MButton, MEmpty, MField, MHead, MRule, MTop, inputStyle, useToast } from "@/components/m";
import { roundLines, segment } from "@/components/m/work/util";
const BATCH = 40; // pickup.deliverWard takes at most 40 bags a call
export default function RoundSign() {
const ward = segment(useParams<{ ward: string }>().ward);
const { s, mutate } = useSnap();
const { byId, staffById } = useDerived();
const toast = useToast();
// Held from the first render: delivering removes the ward from the sheet, and the Done screen
// must not turn into an empty round.
const [rows] = useState(() => roundSheet(s, byId, staffById).find((w) => w.ward === ward)?.rows ?? []);
const lines = useMemo(() => roundLines(rows, byId), [rows, byId]);
const [name, setName] = useState("");
const [proofId, setProofId] = useState<string | null>(null);
const [photoBusy, setPhotoBusy] = useState(false);
const delivered = useRef(new Set<string>());
if (!rows.length) {
return (
<>
<MTop title="Sign" back />
<MRule />
<MBody pad>
<MEmpty title="Nothing to deliver" />
<MButton label="Back to Work" href="/m/work?seg=rounds" />
</MBody>
</>
);
}
const items = lines.reduce((t, l) => t + l.qty, 0);
const people = new Set(rows.map((r) => r.p.staffId)).size;
const who = name.trim();
const takeProof = async () => {
if (photoBusy) return;
const data = await takePhoto();
if (!data) return;
setPhotoBusy(true);
const up = await uploadPhoto(mutate, "proof", data);
setPhotoBusy(false);
if ("error" in up) { toast(up.error); return; }
setProofId(up.id);
};
return (
<SignFlow
kind="round"
head={<MHead name={`${ward} round`} meta={`${plural(people, "person", "people")} · ${plural(items, "item")}`} />}
lines={lines.map((l) => ({ key: l.key, name: l.name, qty: l.qty }))}
signerName={who || `${ward}, nurse in charge`}
extra={
<>
<MField label="Received by">
<input value={name} onChange={(e) => setName(e.target.value)} autoComplete="off" maxLength={120} style={inputStyle} />
</MField>
<MButton small label={photoBusy ? "Saving photo…" : proofId ? "Photo attached · retake" : "Add handover photo"} onClick={() => void takeProof()} disabled={photoBusy} />
</>
}
barLabel="Delivered"
commit={async ({ sigId }) => {
// A ward with more bags than one call takes goes in batches; a retry skips what already went.
const ids = rows.map((r) => r.p.id).filter((x) => !delivered.current.has(x));
for (let i = 0; i < ids.length; i += BATCH) {
const part = ids.slice(i, i + BATCH);
const r = await mutate("pickup.deliverWard", { ids: part, deliveredTo: who, sigId, proofId });
if (!r.ok) return { ok: false, error: delivered.current.size ? `${delivered.current.size} of ${rows.length} bags recorded. ${r.error}` : r.error };
part.forEach((x) => delivered.current.add(x));
}
return {
ok: true,
done: { head: `${ward} delivered`, sub: `${plural(items, "item")} · signed by ${who || "the nurse in charge"}`, shelfKeys: [], next: "work" },
};
}}
/>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/* Delivery rounds are Work Rounds; each ward signs at /m/round/[ward]. */
export default function Rounds() {
redirect("/m/work?seg=rounds");
}
+171
View File
@@ -0,0 +1,171 @@
"use client";
/* The Scan tab: one camera for a staff badge, a garment or a shelf label, routed by lib/scanroute.
Under the Android shell MLKit's preview shows through the transparent viewfinder; in a browser a
<video> fills it through lib/webscan. */
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useSnap } from "@/lib/client";
import { isNative, startLive } from "@/lib/nativescan";
import { startWebLive } from "@/lib/webscan";
import { scanReject, scanTick } from "@/lib/feedback";
import { track } from "@/lib/analytics";
import { useKeepAwake } from "@/lib/wakelock";
import { SCAN_KIND_LABEL, resolveScan, type ScanHit } from "@/lib/scanroute";
import { AC3, GROUND, INK, MBody, MButton, MONO, MPill, MRule, MSection, MTabs, MTop, useScanFlash } from "@/components/m";
type Recent = { kind: string; label: string; code: string; href: string };
/* What this session resolved, newest first. Module memory: it survives moving between tabs and is
gone when the app is closed. */
let RECENT: Recent[] = [];
const OFF_GREY = "#b5b1af";
const EDGE = "#57534f";
const DEBOUNCE = 1500;
export default function ScanTab() {
const { s, isAdmin } = useSnap();
const router = useRouter();
const flash = useScanFlash();
const vid = useRef<HTMLVideoElement | null>(null);
const [armed, setArmed] = useState(true);
const [miss, setMiss] = useState<Extract<ScanHit, { kind: "unknown" | "inactive" }> | null>(null);
const [caption, setCaption] = useState<{ msg: string; denied: boolean } | null>(null);
const [recent, setRecent] = useState<Recent[]>([]);
const [typed, setTyped] = useState("");
const native = isNative();
const [nativeOk, setNativeOk] = useState(native);
const [nativeLive, setNativeLive] = useState(false);
useKeepAwake(true);
useEffect(() => { setRecent(RECENT); }, []);
const handle = useCallback((raw: string) => {
const hit = resolveScan(s, raw);
if (hit.kind === "unknown" || hit.kind === "inactive") {
scanReject();
if (hit.kind === "unknown") track("scan_miss", { kind: "unknown" });
setArmed(false);
setMiss(hit);
return;
}
setArmed(false);
const row: Recent = { kind: SCAN_KIND_LABEL[hit.kind], label: hit.label, code: raw.trim(), href: hit.href };
RECENT = [row, ...RECENT.filter((r) => r.href !== row.href)].slice(0, 4);
setRecent(RECENT);
flash(row.kind, row.label, () => router.push(hit.href));
}, [s, flash, router]);
const handleRef = useRef(handle); handleRef.current = handle;
// Native: MLKit live session behind the WebView while armed.
useEffect(() => {
if (!native || !nativeOk || !armed) return;
let session: { stop: () => Promise<void> } | null = null;
let cancelled = false;
let lastRaw = "", lastT = 0;
(async () => {
const r = await startLive((raw) => {
if (raw === lastRaw && Date.now() - lastT < DEBOUNCE) return;
lastRaw = raw; lastT = Date.now();
scanTick();
handleRef.current(raw);
});
if (cancelled) { await r.stop(); return; }
if (r.error === "native-unavailable") { setNativeOk(false); return; }
if (r.error) { setCaption({ msg: r.error, denied: true }); return; }
session = r;
setNativeLive(true);
})();
return () => { cancelled = true; setNativeLive(false); if (session) void session.stop(); };
}, [native, nativeOk, armed]);
// Browser: the shared BarcodeDetector loop into the viewfinder's video.
useEffect(() => {
if (nativeOk || !armed || !vid.current) return;
const session = startWebLive(vid.current, (raw) => handleRef.current(raw), DEBOUNCE, {
onStatus: (st) => setCaption(st.state === "ready" ? null : { msg: st.msg, denied: st.state === "denied" }),
});
return () => session.stop();
}, [nativeOk, armed]);
useEffect(() => {
track("scan_opened", { mode: "tab", engine: nativeOk ? "mlkit" : native ? "mlkit-missing" : "browser" });
}, [native, nativeOk]);
const again = () => { setMiss(null); setCaption(null); setArmed(true); };
const find = () => { const c = typed.trim(); if (!c) return; setTyped(""); handle(c); };
return (
<div className="tcx-scanui" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<MTop title="Scan" />
<MRule />
<MBody dark pad className="tcx-scanbody">
<div style={{ position: "relative", height: 300, margin: "-16px -16px 0", overflow: "hidden", background: nativeLive ? "transparent" : "radial-gradient(ellipse at 50% 45%, #3a3735 0, #151413 70%)" }} className="tcx-camwin">
{!nativeOk && <video ref={vid} autoPlay playsInline muted aria-hidden="true" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }} />}
<div aria-hidden="true" style={{ position: "absolute", left: 16, top: 14, display: "flex", gap: 8, alignItems: "center", color: "#fff", fontSize: 12, fontWeight: 800, letterSpacing: "0.1em" }}>
<i style={{ width: 10, height: 10, background: armed ? "var(--color-accent)" : EDGE }} />LIVE
</div>
<div aria-hidden="true" style={{ position: "absolute", left: "14%", right: "14%", top: "22%", bottom: "26%" }}>
{([["left", "top"], ["right", "top"], ["left", "bottom"], ["right", "bottom"]] as const).map(([x, y]) => (
<i key={x + y} style={{ position: "absolute", width: 34, height: 34, [x]: 0, [y]: 0, borderStyle: "solid", borderColor: "#fff", borderWidth: 0, [`border${y === "top" ? "Top" : "Bottom"}Width`]: 4, [`border${x === "left" ? "Left" : "Right"}Width`]: 4 }} />
))}
</div>
{armed && !caption?.denied && <div aria-hidden="true" className="tcx-vf-laser" style={{ position: "absolute", left: "8%", right: "8%", height: 2, background: "var(--color-accent)", boxShadow: "0 0 12px var(--color-accent)" }} />}
<div role={caption?.denied ? "alert" : undefined} style={{ position: "absolute", left: 12, right: 12, bottom: 16, textAlign: "center", color: caption ? AC3 : "#fff", fontWeight: 800, fontSize: 14, letterSpacing: caption ? 0 : "0.06em", textTransform: caption ? "none" : "uppercase" }}>
{caption ? caption.msg : "Badge, garment or shelf label"}
</div>
</div>
<div style={{ background: INK, margin: "0 -16px -16px", padding: "0 16px 16px", minHeight: "calc(100% - 268px)" }}>
<div style={{ height: 1 }} />
{miss ? (
<div style={{ paddingTop: 18 }}>
<MPill tone="accent">{miss.kind === "unknown" ? "Not found" : "Inactive"}</MPill>
<div style={{ fontFamily: miss.kind === "unknown" ? MONO : undefined, fontSize: 22, fontWeight: miss.kind === "unknown" ? 500 : 800, marginTop: 10, wordBreak: "break-all" }}>
{miss.kind === "unknown" ? miss.code || "—" : miss.label}
</div>
<div style={{ display: "grid", marginTop: 4 }}>
{miss.kind === "unknown" && (isAdmin
? <DarkButton label="Bind to a garment" href={`/m/catalogue?bind=${encodeURIComponent(miss.code)}`} />
: <div style={{ fontSize: 13, color: OFF_GREY, marginTop: 10 }}>Only an admin can bind a code</div>)}
{miss.kind === "inactive" && <DarkButton label="Open their record" href={`/m/person/${miss.staffId}`} />}
<DarkButton label="Scan again" onClick={again} />
</div>
</div>
) : recent.length > 0 && (
<>
<MSection label="Recent scans" right={recent.length} />
<div style={{ display: "grid", gap: 8, marginTop: 10 }}>
{recent.map((r) => (
<button key={r.href} type="button" onClick={() => router.push(r.href)}
style={{ display: "flex", alignItems: "center", gap: 12, minHeight: 56, border: "2px solid " + EDGE, background: "transparent", color: GROUND, padding: "0 12px", textAlign: "left", fontFamily: "inherit", fontSize: 15, fontWeight: 700, cursor: "pointer" }}>
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: OFF_GREY, width: 62, flex: "none" }}>{r.kind}</span>
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span>
<span style={{ fontFamily: MONO, fontSize: 12, color: OFF_GREY, fontWeight: 500 }}>{r.code}</span>
</button>
))}
</div>
</>
)}
<form onSubmit={(e) => { e.preventDefault(); find(); }} style={{ marginTop: 22 }}>
<label htmlFor="scan-typed" style={{ display: "block", fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: OFF_GREY, marginBottom: 6 }}>Type a code</label>
<div style={{ display: "flex", gap: 8 }}>
<input id="scan-typed" value={typed} onChange={(e) => setTyped(e.target.value)} autoComplete="off" autoCapitalize="none" autoCorrect="off" spellCheck={false}
style={{ flex: 1, minWidth: 0, height: 52, border: "2px solid " + EDGE, background: "transparent", color: GROUND, fontFamily: MONO, fontSize: 16, padding: "0 14px", borderRadius: 0 }} />
<button type="submit" style={{ minWidth: 72, minHeight: 52, border: "2px solid " + GROUND, background: GROUND, color: INK, fontFamily: "inherit", fontSize: 14, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer" }}>Find</button>
</div>
</form>
</div>
</MBody>
<MTabs active="scan" />
</div>
);
}
/** MButton's outline, drawn in ground on the ink body. */
function DarkButton({ label, href, onClick }: { label: string; href?: string; onClick?: () => void }) {
return (
<div style={{ "--color-text": "var(--color-bg)" } as React.CSSProperties}>
<MButton label={label} href={href} onClick={onClick} />
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { redirect } from "next/navigation";
/* Search became the People tab (stock search is on the Stock tab). The query comes along. */
export default async function Search({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
const q = await searchParams;
const term = typeof q.q === "string" ? q.q.trim().slice(0, 80) : "";
redirect(term ? `/m/people?q=${encodeURIComponent(term)}` : "/m/people");
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
/* Settings, behind the gear on Today: this phone, the account, help and signing out. Everything else
about the facility lives on the desktop, so there is one place a setting can be wrong. */
import { DELETE_ACCOUNT_URL, PRIVACY_URL, TERMS_URL } from "@/lib/links";
import { useEffect, useState } from "react";
import { useSnap } from "@/lib/client";
import { clearAllCounts } from "@/lib/opencount";
import { printState, type PrintState } from "@/lib/nativeprint";
import { clearBasket } from "@/components/MBasket";
import { MBody, MButton, MError, MONO, MPill, MRow, MRule, MSection, MStepper, MSwitchRow, MTop } from "@/components/m";
const BEEP_KEY = "tc.beep";
export default function MSettings() {
const { s, isAdmin, mutate, busy } = useSnap();
const [beep, setBeep] = useState(true);
const [gate, setGate] = useState(s.settings.varianceReason);
const [err, setErr] = useState("");
const [printer, setPrinter] = useState<PrintState | null>(null);
const [host, setHost] = useState("");
const [leaving, setLeaving] = useState(false);
useEffect(() => {
try { setBeep(localStorage.getItem(BEEP_KEY) !== "0"); } catch { /* blocked store */ }
setHost(window.location.host);
let live = true;
printState().then((p) => { if (live) setPrinter(p); }).catch(() => { /* stays blank */ });
return () => { live = false; };
}, []);
const toggleBeep = () => {
const next = !beep;
setBeep(next);
try { localStorage.setItem(BEEP_KEY, next ? "1" : "0"); } catch { /* blocked store */ }
};
const saveGate = async (n: number) => {
setGate(n);
const r = await mutate("settings.update", { varianceReason: n });
if (!r.ok) { setErr(r.error); setGate(s.settings.varianceReason); }
};
const signOut = async () => {
// Part-counted shelves and the half-built basket are this person's, on a phone that is passed
// around a linen room. Cleared before the logout POST so a failed request still leaves the
// device tidy. A full navigation afterwards: the session cookie is gone and every page behind
// it is server-rendered. /m/login, not /auth, keeps them inside the app.
setLeaving(true);
clearAllCounts(s.session.userId);
clearBasket(s.session.userId);
try { await fetch("/api/auth/logout", { method: "POST" }); } catch { /* the cookie check on /m/login decides */ }
window.location.replace("/m/login");
};
const roleWord = s.session.role === "Admin" ? "Admin" : "Issuer";
const tone = printer?.state === "ready" ? "ok" : printer?.state === "unavailable" ? "accent" : "mute";
return (
<>
<MTop title="Settings" back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
<MSection label="This phone" />
<MSwitchRow title="Beep and buzz on scan" on={beep} onToggle={toggleBeep} />
<MRow title="Shelf printer" sub={printer?.sub ?? ""} right={printer ? <MPill tone={tone}>{printer.label}</MPill> : null} />
<MRow title="Server" sub={host} />
<MSection label="Account" />
<MRow title={s.settings.facility} sub={s.session.title || roleWord} />
<MRow title="Count gap needing a reason"
right={isAdmin
? <MStepper n={gate} onChange={saveGate} min={1} max={99} label="gap" />
: <span style={{ fontFamily: MONO, fontWeight: 600, fontSize: 14 }}>{gate}</span>} />
<MRow title="Help" chev href="/app/help/apps/counter-app" />
{/* Deleting an account has to be reachable from inside the app (a Play requirement), and this
is the only place the signed-in counter app names the policies. They go to the site's own
pages: there is one account-deletion flow, and it is the one on the website. */}
{(DELETE_ACCOUNT_URL || PRIVACY_URL || TERMS_URL) && <MSection label="Your account and your data" />}
{DELETE_ACCOUNT_URL && <MRow href={DELETE_ACCOUNT_URL} external title="Delete your account" />}
{PRIVACY_URL && <MRow href={PRIVACY_URL} external title="Privacy policy" />}
{TERMS_URL && <MRow href={TERMS_URL} external title="Terms of use" />}
<div style={{ marginTop: 4 }}>
<MButton label="Sign out" onClick={signOut} disabled={busy || leaving} />
</div>
</MBody>
</>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
/* The old signed-in welcome. Today is the first screen now; a brand-new facility gets its banner. */
export default async function SignedIn({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
const q = await searchParams;
redirect(q.new === "1" ? "/m?flash=created" : "/m");
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
/* The Stock tab: on hand against par, worst first, in three segments, and the stock jobs underneath. */
import { useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { atReorderVariants } from "@/lib/portalcounts";
import { bcBound, flaggedNeeds, label, locMap, locTrail, onhand, reorderAt, touched } from "@/lib/compute";
import { MBody, MButton, MEmpty, MRow, MRule, MSearch, MSection, MSeg, MTabs, MTop, MTopCount } from "@/components/m";
import { PAGE, byShortfall, countRows, placedOnOrder } from "@/components/m/stock/stockdata";
type Seg = "below" | "order" | "all";
const SEGS: Seg[] = ["below", "order", "all"];
export default function MStock() {
const { s } = useSnap();
const { L, byId, variants } = useDerived();
const router = useRouter();
const sp = useSearchParams();
const [seg, setSeg] = useState<Seg>(() => {
const v = sp.get("seg") as Seg | null;
return v && SEGS.includes(v) ? v : "below";
});
const [q, setQ] = useState("");
const [shown, setShown] = useState(PAGE);
const data = useMemo(() => {
const locs = locMap(s);
const onOrd = placedOnOrder(s, byId);
const below = new Set(atReorderVariants(s, L).map((v) => v.key));
const all = variants.filter((v) => touched(s, L, v.key)).map((v) => ({
key: v.key, itemId: v.itemId, si: v.si, size: v.size,
name: label(v.item), oh: onhand(s, L, v.key), par: reorderAt(s, v.key),
code: bcBound(s, v.item, v.si), shelf: locTrail(locs, s.placed[v.key], 0),
below: below.has(v.key), onOrder: (onOrd[v.key] || 0) > 0,
}));
const order = variants.filter((v) => (onOrd[v.key] || 0) > 0).length;
return { all, below: below.size, order, drafts: flaggedNeeds(s, L, byId).length };
}, [s, L, byId, variants]);
const shelves = useMemo(() => countRows(s, L, variants).length, [s, L, variants]);
const rows = useMemo(() => {
const needle = q.trim().toLowerCase();
return data.all
.filter((r) => seg === "all" || (seg === "below" ? r.below : r.onOrder))
.filter((r) => !needle || `${r.name} ${r.size} ${r.code} ${r.shelf}`.toLowerCase().includes(needle))
.sort(byShortfall);
}, [data, seg, q]);
const pick = (k: Seg) => {
setSeg(k); setShown(PAGE);
router.replace(`/m/stock?seg=${k}`, { scroll: false });
};
return (
<>
<MTop title="Stock" right={<MTopCount>{data.below} short</MTopCount>} />
<MRule />
<MBody pad>
<MSearch value={q} onChange={(v) => { setQ(v); setShown(PAGE); }} placeholder="Garment or size" label="Filter stock" scanHref="/m/scan" scanLabel="Scan a garment" />
<div style={{ marginTop: 10 }}>
<MSeg label="Stock lines" value={seg} onPick={pick} options={[
{ key: "below", label: "Below par", n: data.below },
{ key: "order", label: "On order", n: data.order },
{ key: "all", label: "All", n: data.all.length },
]} />
</div>
{rows.length === 0
? <MEmpty title="Nothing here" sub="Try All, or clear the search." />
: rows.slice(0, shown).map((r) => (
<MRow key={r.key} href={`/m/line/${encodeURIComponent(r.itemId)}/${r.si}`}
mark={r.oh <= 0 ? "accent" : r.oh <= r.par ? "ink" : "mute"}
title={`${r.name} ${r.size}`} right={`${r.oh}/${r.par}`} />
))}
{rows.length > shown && (
<>
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 10 }}>Showing {shown} of {rows.length}</div>
<MButton small label="Show more" onClick={() => setShown((n) => n + PAGE)} />
</>
)}
<MSection label="Also in Stock" />
<MRow mark="mute" chev href="/m/reorder" title={`Draft order · ${data.drafts} line${data.drafts === 1 ? "" : "s"}`} />
<MRow mark="mute" chev href="/m/variance" title="Variance over time" />
<MRow mark="mute" chev href="/m/catalogue" title="Catalogue" />
<MRow mark="mute" chev href="/m/count" title={`Count a shelf · ${shelves} shel${shelves === 1 ? "f" : "ves"}`} />
</MBody>
<MTabs active="stock" />
</>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
/* Variance over time: the pattern, not the number. A line short at every count is a different
problem from one short once, so the chart is the point and the latest gap is the figure. */
import { useMemo, useState } from "react";
import { useSnap } from "@/lib/client";
import { itemMap, label, monthLabel } from "@/lib/compute";
import { INK, MBody, MButton, MEmpty, MKick, MONO, MRule, MSection, MTop, MTopCount } from "@/components/m";
const MAX_BAR = 56;
const PAGE = 40;
export default function MVarianceOverTime() {
const { s } = useSnap();
const [shown, setShown] = useState(PAGE);
const { rows, counts } = useMemo(() => {
const byId = itemMap(s);
// Oldest first, shelf counts only, the last six.
const takes = s.stocktakes.filter((t) => t.mode !== "preloved").slice(0, 6).reverse();
const seen: Record<string, { name: string; gaps: (number | null)[] }> = {};
takes.forEach((t, col) => {
for (const l of t.lines) {
const k = `${l.itemId}:${l.si}`;
const it = byId[l.itemId];
if (!it) continue;
(seen[k] ||= { name: `${label(it)} ${it.sizes[l.si] ?? l.si}`, gaps: takes.map(() => null) });
seen[k].gaps[col] = l.counted - l.sys;
}
});
const out = Object.entries(seen).map(([k, v]) => {
const known = v.gaps.filter((g): g is number => g !== null);
const latest = [...v.gaps].reverse().find((g) => g !== null) ?? 0;
const shortEvery = known.length >= 2 && known.every((g) => g < 0);
const worsening = known.length >= 3 && known[known.length - 1] < known[0] && known[known.length - 1] < 0;
const verdict = known.every((g) => g === 0) ? "Steady"
: shortEvery ? `Short at every count since ${monthLabel(takes[v.gaps.findIndex((g) => g !== null)]?.date.slice(0, 7) || "", { month: "long" })}`
: worsening ? "Drifting short"
: latest === 0 ? "Back in line" : "Occasional gap";
return { key: k, name: v.name, gaps: v.gaps, latest, verdict, persistent: shortEvery || worsening };
});
// Worst pattern first: persistent problems, then the biggest gap.
out.sort((a, b) => Number(b.persistent) - Number(a.persistent) || a.latest - b.latest || a.name.localeCompare(b.name));
return { rows: out, counts: takes };
}, [s]);
const peak = Math.max(1, ...rows.flatMap((r) => r.gaps.map((g) => Math.abs(g ?? 0))));
const month = (d: string, m: "short" | "long") => monthLabel(d.slice(0, 7), { month: m });
return (
<>
<MTop title="Variance over time" back right={<MTopCount>{counts.length} count{counts.length === 1 ? "" : "s"}</MTopCount>} />
<MRule />
<MBody pad>
{counts.length > 0 && <MKick>Gap at each count since {month(counts[0].date, "long")}</MKick>}
{rows.length === 0 ? (
<MEmpty title="No counts to compare yet" sub="Commit two shelf counts to see a pattern." />
) : (
<>
<MSection label="Lines" right={rows.length} />
{rows.slice(0, shown).map((r) => (
<div key={r.key} style={{ padding: "12px 0", borderBottom: "1px solid var(--color-divider)", boxShadow: r.persistent ? "inset 4px 0 0 var(--color-accent)" : undefined, paddingLeft: r.persistent ? 12 : 0 }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 700 }}>{r.name}</div>
<div style={{ fontSize: 13, color: r.persistent ? "var(--color-accent-700)" : "var(--color-neutral-600)", fontWeight: r.persistent ? 800 : 400, marginTop: 1 }}>{r.verdict}</div>
</div>
<div style={{ fontFamily: MONO, fontWeight: 600, fontSize: 14, color: r.latest === 0 ? INK : "var(--color-accent-700)", whiteSpace: "nowrap" }}>
{r.latest === 0 ? "Match" : r.latest > 0 ? `+${r.latest}` : `${-r.latest}`}
</div>
</div>
<div className="tcx-chart" style={{ marginTop: 12 }} role="img"
aria-label={`Gap at each count: ${r.gaps.map((g, i) => `${counts[i] ? month(counts[i].date, "short") : ""} ${g === null ? "not counted" : g}`).join(", ")}`}>
{r.gaps.map((g, i) => {
const mag = Math.abs(g ?? 0);
const h = g === null ? 4 : Math.max(4, Math.round((mag / peak) * MAX_BAR));
const col = g === null ? "var(--color-neutral-300)" : mag === 0 ? "var(--color-divider)" : mag >= 3 ? "var(--color-accent)" : INK;
return <i key={i} style={{ height: h, background: col }} />;
})}
</div>
<div aria-hidden="true" style={{ display: "flex", gap: 5, marginTop: 6 }}>
{counts.map((t, i) => (
<span key={i} style={{ flex: 1, textAlign: "center", fontSize: 12, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>
{month(t.date, "short")}
</span>
))}
</div>
</div>
))}
{rows.length > shown && (
<>
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 10 }}>Showing {shown} of {rows.length}</div>
<MButton small label="Show more" onClick={() => setShown((n) => n + PAGE)} />
</>
)}
</>
)}
</MBody>
</>
);
}
+184
View File
@@ -0,0 +1,184 @@
"use client";
/* The Work tab: the queue at the window. Approved requests to pick, deliveries to receive, pickups to
call and ward rounds, counted by the same useWorkCount() as the tab badge and Today. */
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { collectRows, plural, receiveRows, relativeDay, roundSheet } from "@/lib/today";
import { PICK_STATUSES, useWorkCount } from "@/lib/workcount";
import { useRequests } from "@/components/requests/RequestList";
import {
MBody, MButton, MCard, MCardChip, MEmpty, MError, MKick, MPill, MRow, MRule, MSection, MSeg, MTabs, MTop, MTopCount, useToast,
} from "@/components/m";
import { openReceivable, orderWhen, outstandingTotal } from "@/components/m/work/util";
type Seg = "picks" | "in" | "pickups" | "rounds";
const SEGS: readonly Seg[] = ["picks", "in", "pickups", "rounds"];
const asSeg = (v: string | null): Seg => (SEGS.includes(v as Seg) ? (v as Seg) : "picks");
export default function Work() {
const sp = useSearchParams();
const [seg, setSeg] = useState<Seg>(() => asSeg(sp.get("seg")));
const w = useWorkCount();
const [err, setErr] = useState("");
// A link from Today (?seg=in) lands on its segment even when Work is already mounted.
useEffect(() => { setSeg(asSeg(sp.get("seg"))); }, [sp]);
const pick = (k: Seg) => {
setSeg(k);
setErr("");
// Shallow: the segment is only a view of the snapshot already on the phone. null state, so Next's
// router takes the new URL as canonical and a later refresh doesn't put the old segment back.
try { window.history.replaceState(null, "", `/m/work?seg=${k}`); } catch { /* not fatal */ }
};
return (
<>
<MTop title="Work" right={<MTopCount>{w.total} open</MTopCount>} />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
<MSeg label="Work" value={seg} onPick={pick} options={[
{ key: "picks", label: "Picks", n: w.picks },
{ key: "in", label: "In", n: w.in },
{ key: "pickups", label: "Pickups", n: w.pickups },
{ key: "rounds", label: "Rounds", n: w.rounds },
]} />
{seg === "picks" && <Picks />}
{seg === "in" && <Inbound />}
{seg === "pickups" && <Pickups onError={setErr} />}
{seg === "rounds" && <Rounds />}
</MBody>
<MTabs active="work" workBadge={w.total} />
</>
);
}
const line = (text: string) => <span style={{ display: "block" }}>{text}</span>;
function Picks() {
const { s } = useSnap();
const { staffById } = useDerived();
const { data, error, reload } = useRequests();
const rows = useMemo(() => (data?.requests ?? [])
.filter((r) => PICK_STATUSES.has(r.status))
.sort((a, b) => (a.decidedAt || a.createdAt).localeCompare(b.decidedAt || b.createdAt)), [data]);
if (!data) {
if (error) return (
<div style={{ marginTop: 14 }}>
<MError msg="Requests couldnt be loaded." />
<MButton small label="Try again" onClick={() => void reload()} />
</div>
);
return <div style={{ marginTop: 14 }}><MKick>Loading</MKick></div>;
}
if (!rows.length) return <MEmpty title="Nothing to pick" sub="Approved requests land here." />;
return (
<>
{rows.map((r) => {
const st = staffById[r.staffId];
const what = r.bag.map((l) => `${l.item.toLowerCase()} ${l.size}`).join(", ");
const when = relativeDay(r.decidedAt, s);
const approved = [when ? `Approved ${when}` : "Approved", r.managerName].filter(Boolean).join(" · ")
+ (r.status === "ready" && r.collectCode ? ` · ready, code ${r.collectCode}` : "");
return (
<MRow key={r.id} mark="ink" chev href={`/m/request/${r.id}`} title={r.staffName}
sub={<>{line([st?.group || r.ward, what].filter(Boolean).join(" · "))}{line(approved)}</>}
right={r.garments} />
);
})}
</>
);
}
function Inbound() {
const { s } = useSnap();
const { byId, staffById } = useDerived();
const soon = useMemo(() => receiveRows(s, staffById), [s, staffById]);
const later = useMemo(() => {
const ids = new Set(soon.map((r) => r.o.id));
return openReceivable(s, byId).filter((o) => !ids.has(o.id));
}, [s, byId, soon]);
const row = (o: (typeof later)[number], forName: string) => (
<MRow key={o.id} mark="ink" chev href={`/m/receive/${o.id}`} title={`${o.code} · ${o.supplier || "Supplier"}`}
sub={[orderWhen(s, o), plural(o.lines.length, "line"), forName ? `for ${forName}` : ""].filter(Boolean).join(" · ")}
right={outstandingTotal(o, byId)} />
);
if (!soon.length && !later.length) return <MEmpty title="Nothing to receive" sub="Open orders show here when they arrive." />;
return (
<>
{soon.map((r) => row(r.o, r.forName))}
{later.length > 0 && (
<>
<MSection label="Later" right={later.length} />
{later.map((o) => row(o, o.staffId ? (staffById[o.staffId] ? `${staffById[o.staffId].first} ${staffById[o.staffId].last}`.trim() : "") : ""))}
</>
)}
</>
);
}
function Pickups({ onError }: { onError: (msg: string) => void }) {
const { s, mutate } = useSnap();
const { byId, staffById } = useDerived();
const toast = useToast();
const rows = useMemo(() => collectRows(s, byId, staffById, { includeRound: true }), [s, byId, staffById]);
const [busy, setBusy] = useState<string | null>(null);
const run = async (id: string, op: string, payload: Record<string, unknown>, then?: () => void) => {
if (busy) return;
setBusy(id);
onError("");
const r = await mutate(op, payload);
setBusy(null);
if (!r.ok) { onError(r.error); return; }
then?.();
};
if (!rows.length) return <MEmpty title="No one waiting" sub="Orders in for a person show here when they arrive." />;
return (
<>
{rows.map((r) => {
const items = r.lines.map((l) => `${l.garment} ${l.size} ×${l.qty}`).join(", ");
const id = r.p.id;
return (
<MCard key={id} title={r.name} sub={[r.st?.dept, items].filter(Boolean).join(" · ")}
pill={<MPill tone={r.late ? "accent" : "mute"}>{r.days}d</MPill>}
actions={
<>
<MCardChip label="Call" href={r.tel || undefined} disabled={!r.tel} />
<MCardChip label={r.p.contacted ? "Contacted" : "Contacted?"} on={r.p.contacted} disabled={busy === id}
onClick={() => void run(id, "pickup.contacted", { id, contacted: !r.p.contacted })} />
<MCardChip label="Collected" disabled={busy === id}
onClick={() => void run(id, "pickup.pickedUp", { id }, () => toast(`${r.name} collected ${items}`))} />
</>
} />
);
})}
</>
);
}
function Rounds() {
const { s } = useSnap();
const { byId, staffById } = useDerived();
const wards = useMemo(() => roundSheet(s, byId, staffById), [s, byId, staffById]);
if (!wards.length) return <MEmpty title="No rounds today" sub="Delivered rounds are in History on the desktop." />;
return (
<>
{wards.map((w) => {
const people = new Set(w.rows.map((r) => r.p.staffId)).size;
return (
<MRow key={w.ward} mark="ink" chev href={`/m/round/${encodeURIComponent(w.ward)}`} title={w.ward}
sub={`${plural(people, "person", "people")} · ${plural(w.rows.length, "bag")}`} right={w.garments} />
);
})}
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import Analytics from "@/components/Analytics";
export const dynamic = "force-dynamic";
/* `title` has to be absolute here. As a plain string it went through the root layout's
"%s — ThreadCount" template and every screen of the phone app, sign-in included, was titled
"ThreadCount — ThreadCount" in the tab, in history and in a bookmark. The template is re-declared
for the screens below that name themselves, and the canonical points at the app rather than
inheriting the marketing homepage's. */
export const metadata: Metadata = {
title: { absolute: "ThreadCount — the linen room counter", template: "%s — ThreadCount" },
alternates: { canonical: "/m" },
robots: { index: false, follow: false },
};
/* The app is a fixed-height column: bars don't scroll, only the body does.
*
* No maximumScale. Pinning the zoom kept the layout tidy and took pinch-to-zoom away from everyone
* on every screen WCAG 1.4.4, and it matters more than tidiness on a ward phone held at arm's
* length in bad light. The bundled shell's own index.html had the same line removed. */
export const viewport = { width: "device-width", initialScale: 1, viewportFit: "cover" as const, themeColor: "#201e1d" };
/* Only the shell. Sign in and create account live under /m but must be reachable without a
session they are how you get one so the session check sits in (app) with everything else. */
export default function MobileLayout({ children }: { children: React.ReactNode }) {
return (
<div className="tcx-app" role="main">
{/* The shell itself is the main landmark. No skip link on the phone surfaces: navigation is
the bar at the bottom, after the content, so there is no repeated block in front to bypass.
role on the existing box rather than a <main> wrapper the shell is a fixed-height flex
column, and the display:contents that an extra element would need has a long history of
dropping the landmark out of the accessibility tree. */}
{children}
{/* Mounted on /m rather than inside (app): sign in and create account are app screens too,
and they are where the Android shell lands first. */}
<Analytics site="app" />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { Metadata } from "next";
/* Sign in is a client component and can't export metadata of its own, so it gets this. Without it
the page inherited the app shell's title and a browser tab, a history entry and a bookmark for
the sign-in screen all read as if they were the site's front page. */
export const metadata: Metadata = {
title: "Sign in",
alternates: { canonical: "/m/login" },
};
export default function MLoginLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+205
View File
@@ -0,0 +1,205 @@
"use client";
/* Sign in — onboarding screen 03. Public: this is the one /m route someone reaches without a
session, because it is how they get one. */
import Link from "next/link";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import Turnstile, { awaitTurnstile, resetTurnstile, turnstileOn } from "@/components/Turnstile";
import { track } from "@/lib/analytics";
import { MAuthError, MAuthFooter, MAuthHeader, MField, MShowHide, authInput, authLink } from "@/components/MAuth";
function LoginInner() {
const router = useRouter();
const sp = useSearchParams();
const [email, setEmail] = useState("");
const [pw, setPw] = useState("");
const [show, setShow] = useState(false);
const [cfToken, setCfToken] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [reveal, setReveal] = useState(false);
const [forgot, setForgot] = useState(false);
const [sentReset, setSentReset] = useState(false);
const [ticket, setTicket] = useState("");
const [code, setCode] = useState("");
const next = (() => {
const n = sp.get("next") || "";
return n.startsWith("/m") && !n.startsWith("//") ? n : "/m";
})();
async function sendReset() {
setErr("");
if (!email.trim()) { setErr("Put your work email in the box above first."); return; }
setBusy(true);
const token = cfToken || (turnstileOn() ? await awaitTurnstile() : "");
await fetch("/api/auth/forgot", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ email, cfToken: token }),
}).catch(() => {});
setBusy(false);
setCfToken(""); resetTurnstile();
// Shown whatever the server said: the answer must not reveal whether the address has an account.
setSentReset(true);
}
async function submitCode() {
setErr("");
if (!code.trim()) { setErr("Enter the six-digit code from your authenticator app."); return; }
setBusy(true);
const r = await fetch("/api/auth/2fa", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ ticket, code }),
}).catch(() => null);
const j = r ? await r.json().catch(() => ({})) : {};
setBusy(false);
// A dropped connection is not a wrong code, and it used to leave the button spinning for ever
// with nothing said. Nothing is signed in either way, so the advice is simply to try again.
if (!r) {
track("signin_failed", { reason: "network" });
setErr("Couldnt reach the server. Check the connection and try again.");
return;
}
if (!r.ok) {
track("signin_failed", { reason: "second_factor" });
setErr(j.error || "That code isnt right.");
if (r.status === 400) setTicket(""); // the ticket expired — start again
return;
}
track("signin");
window.location.replace(next);
}
async function submit() {
if (!email.trim() || !pw) { setErr("Enter your email and password."); return; }
setBusy(true);
// The widget draws nothing in quiet mode, so nobody can see that it hasn't finished. Wait for
// the token rather than posting an empty one and blaming the person for it.
const token = cfToken || (turnstileOn() ? await awaitTurnstile() : "");
if (turnstileOn() && !token) {
// Eight seconds and no token. Either the check needs an interaction we've asked Turnstile
// not to draw, or it couldn't reach Cloudflare at all. Show the widget rather than send an
// empty token and let the server answer with a check the person was never shown.
setBusy(false);
setReveal(true);
// How often the invisible check has to show itself. If this climbs, the quiet widget is
// costing people sign-ins and should come back out.
track("security_check_shown", { screen: "signin" });
setErr("Finish the security check below, then try again.");
return;
}
const r = await fetch("/api/auth/login", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password: pw, cfToken: token }),
}).catch(() => null);
const j = r ? await r.json().catch(() => ({})) : {};
setBusy(false);
/* The request never arrived. Ward wifi drops, and without this the promise rejected, `busy`
never cleared and the sign-in button sat disabled on its spinner until the app was killed
which reads as ThreadCount refusing to let you in. Nothing was signed in, so the token is
spent and the widget reset like any other failed attempt. */
if (!r) {
track("signin_failed", { reason: "network" });
setErr("Couldnt reach the server. Check the connection and try again.");
setCfToken(""); resetTurnstile();
return;
}
if (!r.ok) {
// Whether it was the password or the security check — no email, no message.
track("signin_failed", { reason: r.status === 429 ? "throttled" : r.status === 400 ? "security_check" : "credentials" });
setErr(j.error || "That email and password dont match.");
setCfToken(""); resetTurnstile();
return;
}
// The password was right but the account has a second factor; nothing is signed in yet.
if (j.need2fa) { setTicket(j.ticket); track("signin_2fa_required"); return; }
track("signin");
// A full navigation, not a router push: the session cookie has just changed and every /m page
// is server-rendered from it. replace(), not assign(), so the hardware back button doesn't
// land a signed-in person back on the sign-in screen.
window.location.replace(next);
}
return (
<>
<MAuthHeader kicker="Linen room access" title="Sign in" />
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
<div style={{ flex: 1, overflowY: "auto", padding: "26px 24px 30px", display: "grid", gap: 26, alignContent: "start" }}>
<p style={{ fontSize: 14, lineHeight: 1.5, color: "var(--color-neutral-800)", maxWidth: 290 }}>
{ticket ? "Your password was right. Now the code from your authenticator app." : "Use the account your linen services manager set up."}
</p>
<MAuthError msg={err} />
{ticket ? (
<>
<MField n="01" label="Six-digit code">
{(c) => (
<input {...c} style={authInput} inputMode="numeric" autoComplete="one-time-code" autoFocus
autoCapitalize="none" autoCorrect="off" spellCheck={false} enterKeyHint="go"
placeholder="000000" value={code}
onChange={(e) => { setCode(e.target.value); setErr(""); }}
onKeyDown={(e) => { if (e.key === "Enter") submitCode(); }} />
)}
</MField>
<p style={{ fontSize: 12.5, lineHeight: 1.6, color: "var(--color-neutral-700)" }}>
Lost your phone? A recovery code works here instead.
</p>
</>
) : (
<>
<MField n="01" label="Work email">
{(c) => (
<input {...c} style={authInput} type="email" inputMode="email" autoCapitalize="none" autoCorrect="off"
spellCheck={false} autoComplete="email" enterKeyHint="next" placeholder="you@yourfacility.org"
value={email} onChange={(e) => { setEmail(e.target.value); setErr(""); }} />
)}
</MField>
<MField n="02" label="Password" right={<MShowHide on={show} onToggle={() => setShow(!show)} />}>
{(c) => (
<input {...c} style={authInput} type={show ? "text" : "password"} autoComplete="current-password"
enterKeyHint="go" value={pw}
onChange={(e) => { setPw(e.target.value); setErr(""); }}
onKeyDown={(e) => { if (e.key === "Enter") submit(); }} />
)}
</MField>
</>
)}
{/* This used to explain that there was no reset and to go and find an admin which was a
dead end for the admin themselves, and deleting the last admin deletes the facility.
Hidden during the code step, where it would be answering a question nobody asked. */}
{ticket ? null : sentReset ? (
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--color-neutral-800)", background: "#fff", borderLeft: "6px solid var(--color-accent)", padding: 14 }}>
If that address has an account, a reset link is on its way. It works once and expires in
an hour.
</p>
) : forgot ? (
<div style={{ background: "#fff", borderLeft: "6px solid var(--color-text)", padding: 14 }}>
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--color-neutral-800)", margin: 0 }}>
Put your work email in the box above and we&rsquo;ll send a link to set a new password.
</p>
<button onClick={sendReset} disabled={busy}
style={{ marginTop: 12, background: "none", border: 0, padding: 0, fontSize: 13.5, fontWeight: 800, color: "var(--color-accent-700)", cursor: busy ? "wait" : "pointer" }}>
{busy ? "Sending…" : "Send me a reset link"}
</button>
</div>
) : (
<button onClick={() => setForgot(true)}
style={{ justifySelf: "start", background: "none", border: 0, padding: 0, fontSize: 13, fontWeight: 600, color: "var(--color-accent-700)", cursor: "pointer" }}>
Forgot password
</button>
)}
{turnstileOn() && <Turnstile onToken={setCfToken} action="login" quiet={!reveal} />}
</div>
<MAuthFooter
secondary={ticket
? <button onClick={() => { setTicket(""); setCode(""); setErr(""); }} style={{ background: "none", border: 0, padding: 0, font: "inherit", fontSize: 13, fontWeight: 700, color: "var(--color-accent-700)", cursor: "pointer" }}>Start again</button>
: <>No account yet? <Link href="/m/signup" style={authLink}>Sign up</Link></>}
label={ticket ? "Verify" : "Sign in"} onSubmit={ticket ? submitCode : submit} busy={busy} />
</>
);
}
export default function MLogin() {
return <Suspense fallback={null}><LoginInner /></Suspense>;
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
/* Same reason as the sign-in screen next door: a client page can't name itself, and "create an
account" is not the site's front page. */
export const metadata: Metadata = {
title: "Create an account",
alternates: { canonical: "/m/signup" },
};
export default function MSignupLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

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