commit 1bc2de655a45c72c813d27dbf952497d607f2024 Author: ThreadCount Date: Sun Sep 13 08:45:19 2026 +1000 ThreadCount Community edition Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..38f1ef8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +node_modules +.next +.deploy-prev +.photos +.git +.gitea +.claude +android +android-staff +androidshell +docs +patches/*.orig +*.aab +*.apk +.env +.env.* +!.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e3db096 --- /dev/null +++ b/.env.example @@ -0,0 +1,107 @@ +# Every variable the application actually reads, so a rebuilt secrets file cannot silently drop one. +# On the server this is /etc/threadcount/secrets.env; locally it is .env.local. +# +# The variables beginning NEXT_PUBLIC_ are compiled into the browser bundle at build time, not read +# at runtime — set them before `next build`, and never put anything secret in one. + +# ---- required. instrumentation.ts refuses to start production without these ---- +DATABASE_URL=postgresql://user:pass@localhost:5432/threadcount +# Signs every session cookie. Generate one per environment (`openssl rand -base64 48`) and never +# ship the placeholder — production refuses to start while it still says change-me. +SESSION_SECRET=change-me +# Cloudflare Turnstile. Protects sign-in, sign-up, password reset, the contact form and the update +# list. Missing in production means either an unprotected front door or an authentication outage, +# so the server refuses to start without both. +TURNSTILE_SECRET= +NEXT_PUBLIC_TURNSTILE_SITEKEY= + +# ---- transactional mail ---- +# With these unset nothing is sent: a request is still raised, and the screens say plainly that +# nobody was emailed rather than claiming otherwise. +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM="ThreadCount " +# Where the website's contact form is delivered. +CONTACT_TO= + +# ---- addresses and storage ---- +# Absolute base URL, used for links in emails (approval links, password resets). +NEXT_PUBLIC_SITE_URL=https://threadcount.tech +# Where uploaded photographs are written. Must be on a volume that is backed up with the database. +PHOTO_DIR=/var/lib/threadcount/photos + +# ---- Android app links ---- +# The SHA-256 signing-certificate fingerprints served from /.well-known/assetlinks.json. Several may +# be listed per app, comma-separated. ANDROID_APP_FINGERPRINTS is the fallback for both. +ANDROID_APP_FINGERPRINTS_COUNTER= +ANDROID_APP_FINGERPRINTS_STAFF= + +# ---- operations console (ops.threadcount.tech) ---- +# Signs the operator session cookie (tc_ops). Its OWN secret, never SESSION_SECRET: that one also +# derives the key encrypting every customer's TOTP secret, so sharing it would mean a compromised +# operator credential forces a rotation that destroys every facility's second factor. Generate it +# the same way (`openssl rand -base64 48`). Only the console reads it; the product starts without it. +OPS_SESSION_SECRET= +# The console reads customer data through restricted Postgres roles, never the app role: +# ops_ro — SELECT on control-plane columns only (counts, dates, configuration). +# ops_reveal — SELECT on a facility's id and three coordinator contact columns, nothing else. +# Same host and database as DATABASE_URL, different user. Create the roles by hand as postgres; +# the grants are in prisma/migrations (ops_ro_grants, ops_reveal_grants). +OPS_DATABASE_URL= +OPS_REVEAL_DATABASE_URL= +# Single sign-on for FACILITIES (coordinators and, if a facility allows it, staff): the self-hosted +# BoxyHQ Jackson broker at sso.threadcount.tech holds each facility's IdP metadata. With either +# unset there is no SSO anywhere in the product; the key authenticates the management API only. +JACKSON_URL= +JACKSON_API_KEY= +# Single sign-on for the console: ops.threadcount.tech sits behind a Cloudflare Access application +# (Authentik + one-time-PIN). With BOTH set, the app verifies the Access assertion (RS256, fail +# closed) and mints the operator session from the verified email; an email Access admits but the +# console does not know falls back to the password door. With either unset there is no SSO. +CF_ACCESS_TEAM_DOMAIN= +CF_ACCESS_AUD= +# Every contact reveal emails a notice here (who, which facility, why, until when — never the +# contacts). Defaults to the revealing operator's own address. +OPS_ALERT_TO= + +# ---- optional switches ---- +# 1 hides the create-account form and refuses the signup endpoint. +SIGNUPS_DISABLED= +# 1 takes the public demo facility out of service. +DEMO_DISABLED= +# Bearer token the demo facility's scheduled reset presents. +DEMO_RESET_TOKEN= +# Local production-mode smoke tests ONLY: 1 makes Turnstile advisory so `next start` and the e2e +# runners work on a box with no Cloudflare keys. NEVER set this in /etc/threadcount/secrets.env. +TURNSTILE_OPTIONAL= + +# ---- optional: analytics, errors, update list ---- +# All three default to ThreadCount's own self-hosted infrastructure; set them only to point +# somewhere else. +NEXT_PUBLIC_UMAMI_SITE_ID= +NEXT_PUBLIC_UMAMI_APP_ID= +NEXT_PUBLIC_GLITCHTIP_DSN= +# Stamped on error reports so a fault can be tied to a deploy. +NEXT_PUBLIC_RELEASE= +LISTMONK_URL= +LISTMONK_LIST_UUID= + +# ---- local development only ---- +# 1 is required by the embedded `prisma dev` server, which cannot handle concurrent queries. +# Never set it in production: it serialises every database call in the app. +DB_POOL_MAX= + +# Plans. 1 forces plans live on this box whatever the console switch says: new sign-ups start on +# Hosted Small (60 staff records) and Settings shows the Plan tab. Leave unset; flip it from the +# console once the notice to existing rooms has run. +# PLANS_LIVE=1 + +# ---- edition ---- +# "community" = the self-hosted edition (Dockerfile / docker-compose.yml): every feature, no plans, +# no staff ceiling, no demo, no operations console, no error reports or usage statistics sent +# anywhere, and Turnstile optional. Leave unset on threadcount.tech. +EDITION= +# Only docker-compose.yml reads this: the password of the bundled Postgres. +POSTGRES_PASSWORD= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb5a36c --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +# 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 + +# scripts/deploy.sh 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 scripts/deploy.sh on the box; read by the operations console +.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/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..161e50a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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 +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a50a80d --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# ThreadCount + +Uniform stock management for healthcare linen rooms: what is on the shelf, who took it, what it +cost the ward, and the supplier orders and stocktakes in between. Written by a hospital uniform +coordinator for their own room. + +- **Hosted** at [threadcount.tech](https://threadcount.tech) — free for a room under 60 staff + records, paid hosting for a facility, a bundle for a health service. [Pricing](https://threadcount.tech/pricing). +- **Community edition** — this repository, run on your own server. Every feature, no plans, no + ceiling, nothing reported anywhere. Licensed under the [GNU AGPL v3](LICENSE). + +The two editions are the same code. `EDITION=community` in the environment is the only difference, +and [docs/self-hosting.md](docs/self-hosting.md) says exactly what it changes. + +## Run it yourself + +You need Docker with Compose, a machine with 2 GB of memory, and a hostname with TLS in front of +it (any reverse proxy — Caddy, nginx, Traefik). The app sets `Secure` cookies in production, so it +will not sign anyone in over plain HTTP from another machine. + +```sh +git clone https://gitea.pricehq.tech/kyle/threadcount-community.git +cd threadcount-community +cp .env.example .env +``` + +Edit `.env` and set at least: + +| Variable | What it is | +|---|---| +| `SESSION_SECRET` | Signs every session cookie. `openssl rand -base64 48`. The server refuses to start while it says `change-me`. | +| `POSTGRES_PASSWORD` | The bundled database's password. Anything long. | +| `NEXT_PUBLIC_SITE_URL` | The address people will open the app at, e.g. `https://uniforms.example.health`. Compiled in at build time, so set it before the first build. | +| `EDITION` | `community`. | +| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM` | Optional. Without them nothing is emailed — password resets and manager approval links then have to be handled at the counter, and the screens say so. | + +Then: + +```sh +docker compose up -d --build +``` + +The first start creates the database schema. Open your hostname, choose **Create account**, name +your facility: you are its first administrator. Load the catalogue and staff register from CSV +under Settings › Data (templates are there), and set `SIGNUPS_DISABLED=1` in `.env` once your +facility exists so nobody else can create one on your instance. + +Upgrading, backups, restoring, single sign-on, Turnstile, and what the two Android apps can and +cannot do against a self-hosted instance: [docs/self-hosting.md](docs/self-hosting.md). + +## Developing + +Node 24 and a Postgres. `npm ci`, put `DATABASE_URL` and `SESSION_SECRET` in `.env`, then +`npx prisma migrate deploy && npm run dev`. The end-to-end suites are plain bash and curl against a +dev server on port 3111: `scripts/e2e*.sh`. `scripts/check-identity.sh` must stay clean. + +## Licence + +GNU Affero General Public License v3.0. Run it, change it, host it for others — and if you host a +changed version for others, they get the changed source too. See [LICENSE](LICENSE). diff --git a/android-staff/.gitignore b/android-staff/.gitignore new file mode 100644 index 0000000..48354a3 --- /dev/null +++ b/android-staff/.gitignore @@ -0,0 +1,101 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml diff --git a/android-staff/app/.gitignore b/android-staff/app/.gitignore new file mode 100644 index 0000000..043df80 --- /dev/null +++ b/android-staff/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/android-staff/app/build.gradle b/android-staff/app/build.gradle new file mode 100644 index 0000000..1b46b27 --- /dev/null +++ b/android-staff/app/build.gradle @@ -0,0 +1,108 @@ +apply plugin: 'com.android.application' + +// Release signing. The keystore and its password live in ~/threadcount-keys, outside the repo — +// nothing secret is ever committed. Without either file the release build is simply unsigned, so a +// fresh clone still builds. +// +// staff-keystore.properties wins if it exists, so this app can be given its own upload key later +// without touching the build; until then it shares the counter app's, which is what Play expects +// anyway — with Play App Signing the upload key only authenticates the upload, and two listings +// from one publisher sharing one is ordinary. Splitting them is a decision worth making +// deliberately, not by default. +def keystoreProps = new Properties() +def staffKeys = file("${System.getProperty('user.home')}/threadcount-keys/staff-keystore.properties") +def sharedKeys = file("${System.getProperty('user.home')}/threadcount-keys/keystore.properties") +def keystorePropsFile = staffKeys.exists() ? staffKeys : sharedKeys +if (keystorePropsFile.exists()) { + keystorePropsFile.withInputStream { keystoreProps.load(it) } +} + +android { + namespace "tech.threadcount.staff" + compileSdk rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "tech.threadcount.staff" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + // Play permanently reserves a version code the moment a bundle is ingested, even into a + // discarded draft — it can never be reused. Bump this for EVERY upload, not every release. + // 1 was spent on the first bundle, before the sign-in rework, app links and the video. + // 2 was uploaded before the sign-out fixes. Play keeps a code the moment it ingests a + // bundle, so neither can ever be reused. + versionCode 7 + versionName "1.2" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + aaptOptions { + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + signingConfigs { + release { + if (keystoreProps['storeFile']) { + storeFile file(keystoreProps['storeFile']) + storePassword keystoreProps['storePassword'] + keyAlias keystoreProps['keyAlias'] + keyPassword keystoreProps['keyPassword'] + } + } + } + buildTypes { + release { + if (keystoreProps['storeFile']) { + signingConfig signingConfigs.release + } + // R8 shrinks and obfuscates, and Gradle folds the resulting mapping.txt into the + // bundle, which is what lets Play symbolicate a stack trace instead of showing + // a.b.c(). Capacitor ships its plugin keep-rules as consumerProguardFiles, so the + // classes the bridge loads reflectively survive; proguard-rules.pro pins the rest. + // + // Resource shrinking is deliberately left off, as in the counter app: the bundled + // splash and welcome are plain files under assets/, which resource shrinking never + // inspects, so it would trade a real risk for almost no bytes. + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + + // This app ships no native code at all — the barcode scanner and its MLKit/CameraX + // .so files are stripped out by scripts/build-staff-aab.sh before the build. The + // block stays so that the day it does, the symbols go with it without anyone having + // to remember. + ndk { + debugSymbolLevel 'FULL' + } + } + } + // Devices from Android 15 can run 16 KB memory pages, and Play refuses uploads whose native + // libraries are only 4 KB-aligned. Uncompressed + page-aligned .so files satisfy both. + packaging { + jniLibs { + useLegacyPackaging false + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } +} + +repositories { + flatDir{ + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + // WebViewCompat / WebViewFeature, for MainActivity.giveTheSiteTheBridge(). The Capacitor + // module has this as an implementation dependency, which does not reach this module. + implementation "androidx.webkit:webkit:$androidxWebkitVersion" + implementation project(':capacitor-android') + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" + implementation project(':capacitor-cordova-android-plugins') +} + +apply from: 'capacitor.build.gradle' diff --git a/android-staff/app/capacitor.build.gradle b/android-staff/app/capacitor.build.gradle new file mode 100644 index 0000000..f53529e --- /dev/null +++ b/android-staff/app/capacitor.build.gradle @@ -0,0 +1,21 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + implementation project(':capacitor-mlkit-barcode-scanning') + implementation project(':capacitor-browser') + implementation project(':capacitor-haptics') + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/android-staff/app/proguard-rules.pro b/android-staff/app/proguard-rules.pro new file mode 100644 index 0000000..d5a7685 --- /dev/null +++ b/android-staff/app/proguard-rules.pro @@ -0,0 +1,34 @@ +# R8 rules for the ThreadCount Staff shell. +# +# Capacitor's own AAR already contributes consumerProguardFiles that keep anything extending +# com.getcapacitor.Plugin and the @CapacitorPlugin / @PluginMethod members. These rules cover the +# things that sit outside that net — everything the bridge, the WebView or the manifest reaches by +# name rather than by a reference R8 can see. +# +# This app carries no plugins at all: scripts/build-staff-aab.sh strips the barcode scanner and +# haptics out of the generated gradle files and empties assets/capacitor.plugins.json after every +# sync, so there is nothing here to keep for them. Rules naming those packages were inherited from +# the counter app's copy and matched nothing. + +# The bridge, its WebView plumbing, and the annotations that drive plugin dispatch. +-keep class com.getcapacitor.** { *; } +-keep interface com.getcapacitor.** { *; } +-keep @interface com.getcapacitor.** { *; } + +# Anything the WebView calls from JavaScript. proguard-android.txt carries this rule too; it is +# repeated here because losing it silently breaks every call from the page into the app. +-keepclassmembers class * { + @android.webkit.JavascriptInterface ; +} + +# The activity is named in AndroidManifest.xml, and it subclasses BridgeWebViewClient to keep the +# back button honest and to follow an approval link into the right page. +-keep class tech.threadcount.staff.** { *; } + +# Keep source file and line numbers in stack traces, and tell Play's symbolicator where to look. +# Without these a crash report names the class but not the line that threw. +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile + +# Annotations drive both Capacitor's dispatch and AndroidX's lifecycle wiring. +-keepattributes *Annotation*,Signature,InnerClasses,EnclosingMethod diff --git a/android-staff/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/android-staff/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java new file mode 100644 index 0000000..f2c2217 --- /dev/null +++ b/android-staff/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import android.content.Context; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + assertEquals("com.getcapacitor.app", appContext.getPackageName()); + } +} diff --git a/android-staff/app/src/main/AndroidManifest.xml b/android-staff/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9f8baec --- /dev/null +++ b/android-staff/app/src/main/AndroidManifest.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-staff/app/src/main/java/tech/threadcount/staff/MainActivity.java b/android-staff/app/src/main/java/tech/threadcount/staff/MainActivity.java new file mode 100644 index 0000000..277e2af --- /dev/null +++ b/android-staff/app/src/main/java/tech/threadcount/staff/MainActivity.java @@ -0,0 +1,217 @@ +package tech.threadcount.staff; + +import android.content.Intent; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; +import android.webkit.WebView; + +import androidx.activity.OnBackPressedCallback; +import androidx.webkit.WebViewCompat; +import androidx.webkit.WebViewFeature; + +import com.getcapacitor.Bridge; +import com.getcapacitor.BridgeActivity; +import com.getcapacitor.BridgeWebViewClient; +import com.getcapacitor.JSExport; +import com.getcapacitor.PluginHandle; + +import java.lang.reflect.Field; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +/** + * Back navigation. + * + * Capacitor 6 leaves the back button alone, and nothing else was handling it, so back finished the + * activity from wherever you were standing: halfway through a request, one back gesture and the + * app was gone. Here back walks the WebView's history instead — which includes the app's own + * client-side routing — and only leaves the app once there is nothing left to go back to. + * + * The callback's enabled flag is kept in step with canGoBack() rather than left permanently on, + * because Android 13+ reads that flag before the gesture starts to decide whether to animate. With + * it accurate, a back gesture at the root peels the app away to reveal the home screen (predictive + * back, switched on by android:enableOnBackInvokedCallback in the manifest); anywhere else it + * stays put and moves the app back one screen. + * + * Links into the app. + * + * The manifest claims https://threadcount.tech/my with autoVerify, and the launcher shortcuts are + * VIEW intents on three deeper pages, so Android hands this activity a URL rather than a bare + * launch. Nothing read it: Capacitor keeps it in Bridge.intentUri and only ever hands it out + * through the @capacitor/app plugin, which this app does not carry — so a manager tapping + * "Approve" in their email landed on the /my home screen with the token dropped, and the three + * shortcuts all opened the same page. Reading the intent here is a dozen lines against a plugin, + * an npm dependency and the plugin-stripping this app's build script already has to do. + * + * Both entry points matter: onCreate for a cold start, onNewIntent because launchMode is + * singleTask, so a tap while the app is in the background resumes this instance and delivers the + * URL there instead — which is the case that made the email link look completely dead. + */ +public class MainActivity extends BridgeActivity { + + /** The one host this app will follow a link into, and the one path prefix it owns. */ + private static final String SITE_HOST = "threadcount.tech"; + private static final String STAFF_PATH = "/my"; + /** Set on an intent once its link has been opened, so it is only ever followed once. */ + private static final String FOLLOWED = "tech.threadcount.staff.LINK_FOLLOWED"; + + private OnBackPressedCallback back; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // A device with no WebView never gets a bridge; there is nothing to navigate. + if (getBridge() == null) return; + + back = new OnBackPressedCallback(false) { + @Override + public void handleOnBackPressed() { + WebView web = getBridge().getWebView(); + if (web != null && web.canGoBack()) { + web.goBack(); + } else { + // Nothing left in this WebView: hand the gesture back to the system. + setEnabled(false); + } + } + }; + getOnBackPressedDispatcher().addCallback(this, back); + + // pushState, replaceState and ordinary navigations all land here, which is what makes the + // enabled flag above trustworthy in a single-page app. + getBridge().setWebViewClient(new BridgeWebViewClient(getBridge()) { + @Override + public void doUpdateVisitedHistory(WebView view, String url, boolean isReload) { + super.doUpdateVisitedHistory(view, url, isReload); + syncBack(view); + } + + @Override + public void onPageStarted(WebView view, String url, Bitmap favicon) { + super.onPageStarted(view, url, favicon); + syncBack(view); + } + + /** + * Capacitor's own version swaps in the bundled "No connection." screen for any main + * frame response that isn't 2xx. That is the wrong diagnosis for most of them: the + * site answered, and its 404 and its branded 500 (which carries the reference someone + * reads out on the phone) are both better pages than a bundled one telling a nurse to + * go and check the ward's wifi. A gateway status is the exception — nothing is + * answering behind the proxy, which is what the offline screen actually describes. + */ + @Override + public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) { + int status = errorResponse != null ? errorResponse.getStatusCode() : 0; + if (status == 502 || status == 503 || status == 504) { + super.onReceivedHttpError(view, request, errorResponse); + } + } + }); + + syncBack(getBridge().getWebView()); + + giveTheSiteTheBridge(); + + openLink(getIntent()); + } + + /** + * Put window.Capacitor on the live site. + * + * The shell opens on its bundled welcome at https://localhost and then hands the WebView to + * threadcount.tech. Capacitor 6 installs its JavaScript bridge with addDocumentStartJavaScript + * scoped to a single origin — the app's own — and, having done that, drops the request-proxy + * path that would otherwise have injected it into pages from the hosts in allowNavigation. So + * every /my page arrived with androidBridge (the message channel is registered for + * allowNavigation hosts too) but no window.Capacitor: the site took itself for a browser, so + * the legal rows promised "a new tab" and loaded the marketing site over the app instead of + * handing it to Chrome through the Browser plugin. Found on a Pixel 8 Pro running the Play + * build, 2026-09-12 — the same defect as the counter app's, fixed the same way. + * + * The identical script Bridge assembles is registered for the site's origin as well. On a + * WebView too old for document-start scripts Capacitor keeps its proxy injector, which already + * covers allowNavigation hosts. The plugin registry is read reflectively (proguard-rules.pro + * keeps com.getcapacitor.** intact); if anything fails the app is exactly as it was before. + */ + private void giveTheSiteTheBridge() { + Bridge bridge = getBridge(); + WebView web = bridge == null ? null : bridge.getWebView(); + if (web == null) return; + if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return; + try { + String script = bridgeScript(bridge); + WebViewCompat.addDocumentStartJavaScript(web, script, Collections.singleton("https://" + SITE_HOST)); + Log.i("ThreadCountStaff", "Capacitor bridge registered for https://" + SITE_HOST); + } catch (Exception e) { + Log.e("ThreadCountStaff", "Could not register the Capacitor bridge for https://" + SITE_HOST + "; the site will run as a browser page", e); + } + } + + /** Bridge.getJSInjector(), piece for piece, using the public JSExport helpers it calls. */ + private String bridgeScript(Bridge bridge) throws Exception { + String globalJS = JSExport.getGlobalJS(this, bridge.getConfig().isLoggingEnabled(), bridge.isDevMode()); + String bridgeJS = JSExport.getBridgeJS(this); + String pluginJS = JSExport.getPluginJS(pluginsOf(bridge)); + String cordovaJS = JSExport.getCordovaJS(this); + String cordovaPluginsJS = JSExport.getCordovaPluginJS(this); + String cordovaPluginsFileJS = JSExport.getCordovaPluginsFileJS(this); + String localUrlJS = "window.WEBVIEW_SERVER_URL = '" + bridge.getLocalUrl() + "';"; + return globalJS + "\n\n" + localUrlJS + "\n\n" + bridgeJS + "\n\n" + pluginJS + "\n\n" + + cordovaJS + "\n\n" + cordovaPluginsFileJS + "\n\n" + cordovaPluginsJS; + } + + @SuppressWarnings("unchecked") + private static Collection pluginsOf(Bridge bridge) throws Exception { + Field f = Bridge.class.getDeclaredField("plugins"); + f.setAccessible(true); + Map plugins = (Map) f.get(bridge); + if (plugins == null || plugins.isEmpty()) throw new IllegalStateException("Bridge has no plugins registered"); + return plugins.values(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + openLink(intent); + } + + /** + * Navigates the WebView to a link this app owns. Anything else — another host, a path outside + * /my, a plain launch from the icon — is left alone, so the shell's own splash and welcome + * still run. + */ + private void openLink(Intent intent) { + String url = staffUrl(intent); + if (url == null || getBridge() == null) return; + // The launch intent reaches this method twice: BridgeActivity.load() routes it through + // onNewIntent while super.onCreate() is still running, and onCreate follows it again + // afterwards in case a future Capacitor stops doing that. Marking the intent means + // whichever arrives first wins and the other is a no-op, rather than the same page being + // loaded twice. A later tap is a different Intent, so it is followed as it should be. + if (intent.getBooleanExtra(FOLLOWED, false)) return; + intent.putExtra(FOLLOWED, true); + WebView web = getBridge().getWebView(); + if (web != null) web.loadUrl(url); + } + + private static String staffUrl(Intent intent) { + if (intent == null || !Intent.ACTION_VIEW.equals(intent.getAction())) return null; + Uri uri = intent.getData(); + if (uri == null) return null; + if (!"https".equals(uri.getScheme()) || !SITE_HOST.equals(uri.getHost())) return null; + String path = uri.getPath(); + if (path == null || !(path.equals(STAFF_PATH) || path.startsWith(STAFF_PATH + "/"))) return null; + return uri.toString(); + } + + private void syncBack(WebView view) { + if (back != null && view != null) back.setEnabled(view.canGoBack()); + } +} diff --git a/android-staff/app/src/main/res/drawable-land-hdpi/splash.png b/android-staff/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000..c9a9bb9 Binary files /dev/null and b/android-staff/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/android-staff/app/src/main/res/drawable-land-mdpi/splash.png b/android-staff/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000..6e839ca Binary files /dev/null and b/android-staff/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/android-staff/app/src/main/res/drawable-land-xhdpi/splash.png b/android-staff/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000..583fe69 Binary files /dev/null and b/android-staff/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/android-staff/app/src/main/res/drawable-land-xxhdpi/splash.png b/android-staff/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000..9e9f85b Binary files /dev/null and b/android-staff/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/android-staff/app/src/main/res/drawable-land-xxxhdpi/splash.png b/android-staff/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000..12d817d Binary files /dev/null and b/android-staff/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/android-staff/app/src/main/res/drawable-port-hdpi/splash.png b/android-staff/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 0000000..d6de562 Binary files /dev/null and b/android-staff/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/android-staff/app/src/main/res/drawable-port-mdpi/splash.png b/android-staff/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 0000000..be1a954 Binary files /dev/null and b/android-staff/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/android-staff/app/src/main/res/drawable-port-xhdpi/splash.png b/android-staff/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 0000000..d0db03e Binary files /dev/null and b/android-staff/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/android-staff/app/src/main/res/drawable-port-xxhdpi/splash.png b/android-staff/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000..de77831 Binary files /dev/null and b/android-staff/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/android-staff/app/src/main/res/drawable-port-xxxhdpi/splash.png b/android-staff/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000..0a2d585 Binary files /dev/null and b/android-staff/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/android-staff/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android-staff/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bd21d --- /dev/null +++ b/android-staff/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/android-staff/app/src/main/res/drawable/ic_launcher_background.xml b/android-staff/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d5fccc5 --- /dev/null +++ b/android-staff/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-staff/app/src/main/res/drawable/splash.png b/android-staff/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000..de77831 Binary files /dev/null and b/android-staff/app/src/main/res/drawable/splash.png differ diff --git a/android-staff/app/src/main/res/drawable/splash_icon.png b/android-staff/app/src/main/res/drawable/splash_icon.png new file mode 100644 index 0000000..8571f28 Binary files /dev/null and b/android-staff/app/src/main/res/drawable/splash_icon.png differ diff --git a/android-staff/app/src/main/res/layout/activity_main.xml b/android-staff/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b5ad138 --- /dev/null +++ b/android-staff/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android-staff/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android-staff/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a0fe943 --- /dev/null +++ b/android-staff/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/android-staff/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android-staff/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a0fe943 --- /dev/null +++ b/android-staff/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/android-staff/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android-staff/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..f2967bf Binary files /dev/null and b/android-staff/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android-staff/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android-staff/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d7aab4c Binary files /dev/null and b/android-staff/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android-staff/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android-staff/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..f2967bf Binary files /dev/null and b/android-staff/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android-staff/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android-staff/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..835cd99 Binary files /dev/null and b/android-staff/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android-staff/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android-staff/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..b4c4be2 Binary files /dev/null and b/android-staff/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android-staff/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android-staff/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..835cd99 Binary files /dev/null and b/android-staff/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android-staff/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android-staff/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..d6dbade Binary files /dev/null and b/android-staff/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android-staff/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android-staff/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..fef32e6 Binary files /dev/null and b/android-staff/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android-staff/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android-staff/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d6dbade Binary files /dev/null and b/android-staff/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android-staff/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android-staff/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..a863d5d Binary files /dev/null and b/android-staff/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android-staff/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android-staff/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e273d2e Binary files /dev/null and b/android-staff/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android-staff/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android-staff/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a863d5d Binary files /dev/null and b/android-staff/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android-staff/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android-staff/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..9b1ab44 Binary files /dev/null and b/android-staff/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android-staff/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android-staff/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e9663f0 Binary files /dev/null and b/android-staff/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android-staff/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android-staff/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..9b1ab44 Binary files /dev/null and b/android-staff/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android-staff/app/src/main/res/values-v31/styles.xml b/android-staff/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..746ec9a --- /dev/null +++ b/android-staff/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/android-staff/app/src/main/res/values/colors.xml b/android-staff/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..c84a713 --- /dev/null +++ b/android-staff/app/src/main/res/values/colors.xml @@ -0,0 +1,9 @@ + + + + #201E1D + #201E1D + #EC3013 + #201E1D + #F3F2F2 + diff --git a/android-staff/app/src/main/res/values/ic_launcher_background.xml b/android-staff/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..175a9b9 --- /dev/null +++ b/android-staff/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,6 @@ + + + + #201E1D + diff --git a/android-staff/app/src/main/res/values/strings.xml b/android-staff/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..2f5e6e6 --- /dev/null +++ b/android-staff/app/src/main/res/values/strings.xml @@ -0,0 +1,16 @@ + + + ThreadCount Staff + ThreadCount Staff + tech.threadcount.staff + tech.threadcount.staff + + + Request + Request an item + My kit + What I\'m holding + Shelf + What\'s on the shelf + diff --git a/android-staff/app/src/main/res/values/styles.xml b/android-staff/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..c3e18ad --- /dev/null +++ b/android-staff/app/src/main/res/values/styles.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + diff --git a/android-staff/app/src/main/res/xml/data_extraction_rules.xml b/android-staff/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..07204fb --- /dev/null +++ b/android-staff/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/android-staff/app/src/main/res/xml/file_paths.xml b/android-staff/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..bd0c4d8 --- /dev/null +++ b/android-staff/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android-staff/app/src/main/res/xml/shortcuts.xml b/android-staff/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 0000000..733a8a4 --- /dev/null +++ b/android-staff/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + diff --git a/android-staff/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/android-staff/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java new file mode 100644 index 0000000..0297327 --- /dev/null +++ b/android-staff/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java @@ -0,0 +1,18 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} diff --git a/android-staff/build.gradle b/android-staff/build.gradle new file mode 100644 index 0000000..85a5dda --- /dev/null +++ b/android-staff/build.gradle @@ -0,0 +1,29 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.2.1' + classpath 'com.google.gms:google-services:4.4.0' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android-staff/capacitor.settings.gradle b/android-staff/capacitor.settings.gradle new file mode 100644 index 0000000..f38c8f8 --- /dev/null +++ b/android-staff/capacitor.settings.gradle @@ -0,0 +1,12 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') + +include ':capacitor-mlkit-barcode-scanning' +project(':capacitor-mlkit-barcode-scanning').projectDir = new File('../node_modules/@capacitor-mlkit/barcode-scanning/android') + +include ':capacitor-browser' +project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android') + +include ':capacitor-haptics' +project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android') diff --git a/android-staff/gradle.properties b/android-staff/gradle.properties new file mode 100644 index 0000000..2e87c52 --- /dev/null +++ b/android-staff/gradle.properties @@ -0,0 +1,22 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true diff --git a/android-staff/gradle/wrapper/gradle-wrapper.jar b/android-staff/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..033e24c Binary files /dev/null and b/android-staff/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android-staff/gradle/wrapper/gradle-wrapper.properties b/android-staff/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c747538 --- /dev/null +++ b/android-staff/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android-staff/gradlew b/android-staff/gradlew new file mode 100755 index 0000000..fcb6fca --- /dev/null +++ b/android-staff/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android-staff/gradlew.bat b/android-staff/gradlew.bat new file mode 100644 index 0000000..6689b85 --- /dev/null +++ b/android-staff/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android-staff/settings.gradle b/android-staff/settings.gradle new file mode 100644 index 0000000..3b4431d --- /dev/null +++ b/android-staff/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/android-staff/variables.gradle b/android-staff/variables.gradle new file mode 100644 index 0000000..3999b90 --- /dev/null +++ b/android-staff/variables.gradle @@ -0,0 +1,28 @@ +ext { + minSdkVersion = 23 + // Play requires new uploads to target a recent API. 36 matches the counter app. + compileSdkVersion = 36 + targetSdkVersion = 36 + androidxActivityVersion = '1.9.3' + androidxAppCompatVersion = '1.7.0' + androidxCoordinatorLayoutVersion = '1.2.0' + androidxCoreVersion = '1.15.0' + androidxFragmentVersion = '1.8.5' + coreSplashScreenVersion = '1.0.1' + androidxWebkitVersion = '1.12.1' + junitVersion = '4.13.2' + androidxJunitVersion = '1.2.1' + androidxEspressoCoreVersion = '3.6.1' + cordovaAndroidVersion = '10.1.1' + + // Kept in step with the counter app's pins even though this project builds without the + // barcode scanner: scripts/build-staff-aab.sh strips the plugin after `cap sync`, and if + // that ever stops happening the build should fail loudly on a missing version rather than + // quietly fall back to a CameraX whose .so files are only 4 KB-aligned. + mlkitBarcodeScanningVersion = '17.3.0' + playServicesMlkitBarcodeScanningVersion = '18.3.1' + androidxCameraCamera2Version = '1.4.2' + androidxCameraCoreVersion = '1.4.2' + androidxCameraLifecycleVersion = '1.4.2' + androidxCameraViewVersion = '1.4.2' +} diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..48354a3 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,101 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 0000000..043df80 --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..6038d98 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,116 @@ +apply plugin: 'com.android.application' + +// Release signing. The keystore and its password live in ~/threadcount-keys, outside the repo — +// nothing secret is ever committed. Without that file the release build is simply unsigned, so a +// fresh clone still builds a debug APK. +def keystorePropsFile = file("${System.getProperty('user.home')}/threadcount-keys/keystore.properties") +def keystoreProps = new Properties() +if (keystorePropsFile.exists()) { + keystorePropsFile.withInputStream { keystoreProps.load(it) } +} + + +android { + namespace "tech.threadcount.app" + compileSdk rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "tech.threadcount.app" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + // Play permanently reserves a version code the moment a bundle is uploaded, even to a + // discarded draft — it can never be reused. Bump this for EVERY upload, not every release. + // 1 was spent on the build with the missing camera permission; 2 on the one before + // onboarding was bundled. 3, 4 and 5 went to Play as drafts — a code is reserved the + // moment Play ingests a bundle, warnings and all. 6 adds the mapping file and the native + // debug symbols Play asked for. + versionCode 9 + versionName "1.3" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + signingConfigs { + release { + if (keystoreProps['storeFile']) { + storeFile file(keystoreProps['storeFile']) + storePassword keystoreProps['storePassword'] + keyAlias keystoreProps['keyAlias'] + keyPassword keystoreProps['keyPassword'] + } + } + } + buildTypes { + release { + if (keystoreProps['storeFile']) { + signingConfig signingConfigs.release + } + // R8 shrinks and obfuscates, and Gradle folds the resulting mapping.txt into the + // bundle, which is what lets Play symbolicate a stack trace instead of showing + // a.b.c(). Capacitor ships its plugin keep-rules as consumerProguardFiles, so the + // classes the bridge loads reflectively survive; proguard-rules.pro pins the rest. + // + // Resource shrinking is deliberately left off: the onboarding splash and welcome are + // plain files under assets/, which resource shrinking never inspects, so it would + // trade a real risk for almost no bytes. + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + + // Play warns that a bundle with native code has no debug symbols. ThreadCount has no + // native code of its own: all three .so files arrive pre-stripped inside Google's + // MLKit and CameraX AARs, and llvm-objcopy --only-keep-debug pulls zero .debug_* and + // zero .symtab sections out of them. So this currently emits nothing, and there is + // nothing it could emit — the warning is unfixable rather than unfixed. It stays + // configured so that the day ThreadCount does ship its own native code, the symbols + // go with it without anyone having to remember. + ndk { + debugSymbolLevel 'FULL' + } + } + } + // Devices from Android 15 can run 16 KB memory pages, and Play refuses uploads whose native + // libraries are only 4 KB-aligned. Uncompressed + page-aligned .so files satisfy both. + packaging { + jniLibs { + useLegacyPackaging false + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } +} + +repositories { + flatDir{ + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + // WebViewCompat / WebViewFeature, for MainActivity.giveTheSiteTheBridge(). The Capacitor + // module has this as an implementation dependency, which does not reach this module. + implementation "androidx.webkit:webkit:$androidxWebkitVersion" + implementation project(':capacitor-android') + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" + implementation project(':capacitor-cordova-android-plugins') +} + +apply from: 'capacitor.build.gradle' + +try { + def servicesJSON = file('google-services.json') + if (servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' + } +} catch(Exception e) { + logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +} diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle new file mode 100644 index 0000000..f53529e --- /dev/null +++ b/android/app/capacitor.build.gradle @@ -0,0 +1,21 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + implementation project(':capacitor-mlkit-barcode-scanning') + implementation project(':capacitor-browser') + implementation project(':capacitor-haptics') + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..2c30700 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,44 @@ +# R8 rules for the ThreadCount shell. +# +# Capacitor's own AAR already contributes consumerProguardFiles that keep anything extending +# com.getcapacitor.Plugin and the @CapacitorPlugin / @PluginMethod members. These rules cover the +# things that sit outside that net — everything the bridge, the WebView or the manifest reaches by +# name rather than by a reference R8 can see. +# +# The cost of getting this wrong is a build that installs and then fails the moment someone scans, +# so the bias here is deliberately towards keeping too much: the bundle is 15 MB of native +# libraries, and none of what follows is where the size is. + +# The plugins named in assets/capacitor.plugins.json. Capacitor resolves these by string at +# startup, so R8 sees no reference to them at all. +-keep class io.capawesome.capacitorjs.plugins.mlkit.barcodescanning.** { *; } +-keep class com.capacitorjs.plugins.haptics.** { *; } + +# The bridge, its WebView plumbing, and the annotations that drive plugin dispatch. +-keep class com.getcapacitor.** { *; } +-keep interface com.getcapacitor.** { *; } +-keep @interface com.getcapacitor.** { *; } + +# Anything the WebView calls from JavaScript. proguard-android.txt carries this rule too; it is +# repeated here because losing it silently breaks every call from the page into the app. +-keepclassmembers class * { + @android.webkit.JavascriptInterface ; +} + +# The activity is named in AndroidManifest.xml, and it subclasses BridgeWebViewClient to keep the +# back button honest. +-keep class tech.threadcount.app.** { *; } + +# MLKit resolves its barcode models and CameraX its implementation classes reflectively. Both ship +# consumer rules of their own; these are belt and braces on the paths that actually run here. +-keep class com.google.mlkit.** { *; } +-keep class com.google.android.gms.internal.mlkit_vision_barcode.** { *; } +-dontwarn com.google.mlkit.** + +# Keep source file and line numbers in stack traces, and tell Play's symbolicator where to look. +# Without these a crash report names the class but not the line that threw. +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile + +# Annotations drive both Capacitor's dispatch and AndroidX's lifecycle wiring. +-keepattributes *Annotation*,Signature,InnerClasses,EnclosingMethod diff --git a/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java new file mode 100644 index 0000000..f2c2217 --- /dev/null +++ b/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import android.content.Context; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + assertEquals("com.getcapacitor.app", appContext.getPackageName()); + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a05e561 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/tech/threadcount/app/MainActivity.java b/android/app/src/main/java/tech/threadcount/app/MainActivity.java new file mode 100644 index 0000000..e23911b --- /dev/null +++ b/android/app/src/main/java/tech/threadcount/app/MainActivity.java @@ -0,0 +1,169 @@ +package tech.threadcount.app; + +import android.graphics.Bitmap; +import android.os.Bundle; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; +import android.webkit.WebView; + +import android.util.Log; + +import androidx.activity.OnBackPressedCallback; +import androidx.webkit.WebViewCompat; +import androidx.webkit.WebViewFeature; + +import com.getcapacitor.Bridge; +import com.getcapacitor.BridgeActivity; +import com.getcapacitor.BridgeWebViewClient; +import com.getcapacitor.JSExport; +import com.getcapacitor.PluginHandle; + +import java.lang.reflect.Field; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +/** + * Back navigation. + * + * Capacitor 6 leaves the back button alone, and nothing else was handling it, so back finished the + * activity from wherever you were standing: three taps into a shelf count, one back gesture and + * ThreadCount was gone. Here back walks the WebView's history instead — which includes the app's + * own client-side routing — and only leaves the app once there is nothing left to go back to. + * + * The callback's enabled flag is kept in step with canGoBack() rather than left permanently on, + * because Android 13+ reads that flag before the gesture starts to decide whether to animate. With + * it accurate, a back gesture at the root peels the app away to reveal the home screen (predictive + * back, switched on by android:enableOnBackInvokedCallback in the manifest); anywhere else it + * stays put and moves the app back one screen. + */ +public class MainActivity extends BridgeActivity { + + private OnBackPressedCallback back; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // A device with no WebView never gets a bridge; there is nothing to navigate. + if (getBridge() == null) return; + + back = new OnBackPressedCallback(false) { + @Override + public void handleOnBackPressed() { + WebView web = getBridge().getWebView(); + if (web != null && web.canGoBack()) { + web.goBack(); + } else { + // Nothing left in this WebView: hand the gesture back to the system. + setEnabled(false); + } + } + }; + getOnBackPressedDispatcher().addCallback(this, back); + + // pushState, replaceState and ordinary navigations all land here, which is what makes the + // enabled flag above trustworthy in a single-page app. + getBridge().setWebViewClient(new BridgeWebViewClient(getBridge()) { + @Override + public void doUpdateVisitedHistory(WebView view, String url, boolean isReload) { + super.doUpdateVisitedHistory(view, url, isReload); + syncBack(view); + } + + @Override + public void onPageStarted(WebView view, String url, Bitmap favicon) { + super.onPageStarted(view, url, favicon); + syncBack(view); + } + + /** + * Capacitor's own version swaps in the bundled "No connection." screen for any main + * frame response that isn't 2xx. That is the wrong diagnosis for most of them: the + * site answered, and its 404 and its branded 500 (which carries the reference someone + * reads out on the phone) are both better pages than a bundled one sending a counter + * off to check the ward's wifi over a problem that isn't the wifi. A gateway status is + * the exception — nothing is answering behind the proxy, which is what the offline + * screen actually describes. + */ + @Override + public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) { + int status = errorResponse != null ? errorResponse.getStatusCode() : 0; + if (status == 502 || status == 503 || status == 504) { + super.onReceivedHttpError(view, request, errorResponse); + } + } + }); + + syncBack(getBridge().getWebView()); + + giveTheSiteTheBridge(); + } + + private void syncBack(WebView view) { + if (back != null && view != null) back.setEnabled(view.canGoBack()); + } + + /** The one origin the shell hands over to, from capacitor.config.ts server.allowNavigation. */ + private static final String SITE_ORIGIN = "https://threadcount.tech"; + + /** + * Put window.Capacitor on the live site. + * + * The shell opens on its bundled welcome at https://localhost and then hands the WebView to + * threadcount.tech. Capacitor 6 installs its JavaScript bridge with addDocumentStartJavaScript + * scoped to a single origin — the app's own, https://localhost — and, having done that, drops + * the request-proxy path that would otherwise have injected it into pages from the hosts in + * allowNavigation. So every page the counter actually uses arrived with androidBridge (the + * message channel is registered for allowNavigation hosts too) but no window.Capacitor: the + * site took itself for a browser, scanned with Chromium's BarcodeDetector instead of MLKit, + * never buzzed, could not hand a link to Chrome, and offered a print button that cannot print + * here. Found on a Pixel 8 Pro running the Play build, 2026-09-12, by evaluating + * typeof window.Capacitor on /m/login: "undefined". + * + * This registers the identical script — the same seven pieces Bridge assembles, in the same + * order — for the site's origin as well. On a WebView too old for document-start scripts + * Capacitor keeps its proxy injector, which already covers allowNavigation hosts, so nothing + * is added there. + * + * The plugin registry is the one piece Bridge keeps private; it is read reflectively, and + * proguard-rules.pro keeps com.getcapacitor.** intact so the field name survives R8. If any + * of this fails the app is exactly as it was before — signed in, working, web scanner — and + * says why in logcat rather than crashing a counter mid-shift. + */ + private void giveTheSiteTheBridge() { + Bridge bridge = getBridge(); + WebView web = bridge == null ? null : bridge.getWebView(); + if (web == null) return; + if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return; + try { + String script = bridgeScript(bridge); + WebViewCompat.addDocumentStartJavaScript(web, script, Collections.singleton(SITE_ORIGIN)); + Log.i("ThreadCount", "Capacitor bridge registered for " + SITE_ORIGIN); + } catch (Exception e) { + Log.e("ThreadCount", "Could not register the Capacitor bridge for " + SITE_ORIGIN + "; the site will run as a browser page", e); + } + } + + /** Bridge.getJSInjector(), piece for piece, using the public JSExport helpers it calls. */ + private String bridgeScript(Bridge bridge) throws Exception { + String globalJS = JSExport.getGlobalJS(this, bridge.getConfig().isLoggingEnabled(), bridge.isDevMode()); + String bridgeJS = JSExport.getBridgeJS(this); + String pluginJS = JSExport.getPluginJS(pluginsOf(bridge)); + String cordovaJS = JSExport.getCordovaJS(this); + String cordovaPluginsJS = JSExport.getCordovaPluginJS(this); + String cordovaPluginsFileJS = JSExport.getCordovaPluginsFileJS(this); + String localUrlJS = "window.WEBVIEW_SERVER_URL = '" + bridge.getLocalUrl() + "';"; + return globalJS + "\n\n" + localUrlJS + "\n\n" + bridgeJS + "\n\n" + pluginJS + "\n\n" + + cordovaJS + "\n\n" + cordovaPluginsFileJS + "\n\n" + cordovaPluginsJS; + } + + @SuppressWarnings("unchecked") + private static Collection pluginsOf(Bridge bridge) throws Exception { + Field f = Bridge.class.getDeclaredField("plugins"); + f.setAccessible(true); + Map plugins = (Map) f.get(bridge); + if (plugins == null || plugins.isEmpty()) throw new IllegalStateException("Bridge has no plugins registered"); + return plugins.values(); + } +} diff --git a/android/app/src/main/res/drawable-land-hdpi/splash.png b/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000..c9a9bb9 Binary files /dev/null and b/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-mdpi/splash.png b/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000..6e839ca Binary files /dev/null and b/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xhdpi/splash.png b/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000..583fe69 Binary files /dev/null and b/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000..9e9f85b Binary files /dev/null and b/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000..12d817d Binary files /dev/null and b/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-hdpi/splash.png b/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 0000000..d6de562 Binary files /dev/null and b/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-mdpi/splash.png b/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 0000000..be1a954 Binary files /dev/null and b/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xhdpi/splash.png b/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 0000000..d0db03e Binary files /dev/null and b/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000..de77831 Binary files /dev/null and b/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000..0a2d585 Binary files /dev/null and b/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bd21d --- /dev/null +++ b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d5fccc5 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/splash.png b/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000..de77831 Binary files /dev/null and b/android/app/src/main/res/drawable/splash.png differ diff --git a/android/app/src/main/res/drawable/splash_icon.png b/android/app/src/main/res/drawable/splash_icon.png new file mode 100644 index 0000000..8571f28 Binary files /dev/null and b/android/app/src/main/res/drawable/splash_icon.png differ diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b5ad138 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..8c5472d Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7f09e3d Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..8c5472d Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..5990912 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..89cf672 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..5990912 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..0d4c068 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e55cbf5 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..0d4c068 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..0bd718c Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..9a63a70 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..0bd718c Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..7d538e4 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..4b801a9 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..7d538e4 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..746ec9a --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..c84a713 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,9 @@ + + + + #201E1D + #201E1D + #EC3013 + #201E1D + #F3F2F2 + diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..90ecd59 --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #F3F2F2 + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..53848c1 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + ThreadCount + ThreadCount + tech.threadcount.app + tech.threadcount.app + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..c3e18ad --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..07204fb --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..bd0c4d8 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java new file mode 100644 index 0000000..0297327 --- /dev/null +++ b/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java @@ -0,0 +1,18 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..85a5dda --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,29 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.2.1' + classpath 'com.google.gms:google-services:4.4.0' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle new file mode 100644 index 0000000..f38c8f8 --- /dev/null +++ b/android/capacitor.settings.gradle @@ -0,0 +1,12 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') + +include ':capacitor-mlkit-barcode-scanning' +project(':capacitor-mlkit-barcode-scanning').projectDir = new File('../node_modules/@capacitor-mlkit/barcode-scanning/android') + +include ':capacitor-browser' +project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android') + +include ':capacitor-haptics' +project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android') diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..2e87c52 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,22 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..033e24c Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c747538 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..fcb6fca --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..6689b85 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..3b4431d --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/android/variables.gradle b/android/variables.gradle new file mode 100644 index 0000000..7e7c38b --- /dev/null +++ b/android/variables.gradle @@ -0,0 +1,30 @@ +ext { + minSdkVersion = 23 + // Play requires new uploads to target a recent API. 36 is what ClearAudit had to move to. + compileSdkVersion = 36 + targetSdkVersion = 36 + androidxActivityVersion = '1.9.3' + androidxAppCompatVersion = '1.7.0' + androidxCoordinatorLayoutVersion = '1.2.0' + androidxCoreVersion = '1.15.0' + androidxFragmentVersion = '1.8.5' + coreSplashScreenVersion = '1.0.1' + androidxWebkitVersion = '1.12.1' + junitVersion = '4.13.2' + androidxJunitVersion = '1.2.1' + androidxEspressoCoreVersion = '3.6.1' + cordovaAndroidVersion = '10.1.1' + + // 16 KB page size: devices from Android 15 onward can use 16 KB memory pages, and Play + // rejects uploads whose native libraries aren't aligned for them. These are the versions + // that ship 16 KB-aligned .so files — the same pin ClearAudit needed. + mlkitBarcodeScanningVersion = '17.3.0' + playServicesMlkitBarcodeScanningVersion = '18.3.1' + // The MLKit plugin reads these four names individually — set them all, or it silently + // falls back to CameraX 1.1.0, whose libimage_processing_util_jni.so is only 4 KB-aligned + // and gets the upload rejected. + androidxCameraCamera2Version = '1.4.2' + androidxCameraCoreVersion = '1.4.2' + androidxCameraLifecycleVersion = '1.4.2' + androidxCameraViewVersion = '1.4.2' +} diff --git a/androidshell/error.html b/androidshell/error.html new file mode 100644 index 0000000..e88a495 --- /dev/null +++ b/androidshell/error.html @@ -0,0 +1,83 @@ + + + + + +No connection — ThreadCount + + + +
ThreadCount
+
+
+

No connection.

+
+

ThreadCount can’t reach the linen room’s records, so it won’t let you issue or count + anything right now — nothing is being saved up in the background.

+
    +
  • Check the ward’s wifi, or step somewhere with signal.
  • +
  • Nothing you had already recorded is lost. A count you hadn’t filed is still + on this phone and will be there when you get back.
  • +
  • If the wifi is fine and this keeps happening, it’s our end — the contact form + on threadcount.tech reaches the person who wrote it.
  • +
+
+ +
+ + + diff --git a/androidshell/index.html b/androidshell/index.html new file mode 100644 index 0000000..6facd77 --- /dev/null +++ b/androidshell/index.html @@ -0,0 +1,246 @@ + + + + + +ThreadCount + + + + +
+ +
ThreadCount
+

Uniform stock, counted
+
+ + + + + + + + diff --git a/androidshell/media/welcome-poster.jpg b/androidshell/media/welcome-poster.jpg new file mode 100644 index 0000000..774153b Binary files /dev/null and b/androidshell/media/welcome-poster.jpg differ diff --git a/androidshell/media/welcome.mp4 b/androidshell/media/welcome.mp4 new file mode 100644 index 0000000..1187a0e Binary files /dev/null and b/androidshell/media/welcome.mp4 differ diff --git a/androidshell/media/wordmark-white.svg b/androidshell/media/wordmark-white.svg new file mode 100644 index 0000000..8575257 --- /dev/null +++ b/androidshell/media/wordmark-white.svg @@ -0,0 +1,5 @@ + + ThreadCount + + + \ No newline at end of file diff --git a/app/(site)/about/page.tsx b/app/(site)/about/page.tsx new file mode 100644 index 0000000..f3dcaed --- /dev/null +++ b/app/(site)/about/page.tsx @@ -0,0 +1,85 @@ +import Link from "next/link"; +import { Band, CtaBand, SiteNav, body, h2, h3, kicker, wrap } from "@/components/site"; +import { plansLive } from "@/lib/plans-live"; + +export const metadata = { + title: "Who built it", + description: "ThreadCount was written by a hospital uniform coordinator for their own linen room, because the workbook stopped being enough.", + alternates: { canonical: "/about" }, +}; + +const STORY = [ + "A hospital linen room runs on memory. Who has how many sets, which sizes are short, what was ordered three weeks ago and never arrived, which ward should be paying for it. Most of that lives in a workbook, a notepad, and the coordinator’s head.", + "That works until it doesn’t. A staff member insists they were never issued a jacket. Finance asks why one ward’s spend doubled. A size runs out on a Monday morning and nobody knew it was low on Friday.", + "ThreadCount was built to answer those questions from a record rather than from recollection. Every garment is scanned to a named person, every issue starts its own replacement order, and every dollar lands on the ward that wore it. The features exist because each one solved a real morning at a real counter.", + "It’s free. No licence, no per-device charge, no sales process. If it’s useful to your room, use it.", +]; +// The last paragraph and the last principle once plans are live: the software is still free, and +// the sentence now says what is and isn't. Everything above them is unchanged — see lib/plans-live.ts. +const STORY_LAST_LIVE = "The software is free — run it yourself and it costs nothing. Hosting it for you is what costs money, and a small room is hosted free. If it’s useful to your room, use it."; +const PRINCIPLE_LAST_LIVE = { t: "Free to run, and portable", b: "The code is published, there is no lock-in, and paying for hosting never locks anything else. Every report and register exports as CSV, and one backup file takes the whole facility. Leaving is as easy as arriving." }; + +const PRINCIPLES = [ + { t: "Built at the counter", b: "Nothing in the product exists because it demonstrates well. Every screen was written to survive a queue of nurses at eight in the morning." }, + { t: "No paperwork tax", b: "The finance outputs are assembled from data already being entered. There’s no second system to keep fed." }, + { t: "Free, and portable", b: "No licence and no lock-in. Every report and register exports as CSV, and one backup file takes the whole facility. Leaving is as easy as arriving." }, +]; + +export default async function About() { + const live = await plansLive(); + const story = live ? [...STORY.slice(0, -1), STORY_LAST_LIVE] : STORY; + const principles = live ? [...PRINCIPLES.slice(0, -1), PRINCIPLE_LAST_LIVE] : PRINCIPLES; + return ( + <> + + + {/* Half-and-half hero: type left, photograph right behind an ink border. */} +
+
+
+
Who built it
+

One coordinator, one linen room, one spreadsheet too many.

+

ThreadCount was written by the person doing the job, for the job. It exists because the workbook stopped being enough.

+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+
+
+ + +
+
The story
+
+ {story.map((p, i) =>

{p}

)} +
+
+
+ + +
Every feature here started as an annoyance, not a roadmap item.
+
+ + +
+ {principles.map((p, i) => ( +
+

{p.t}

+

{p.b}

+
+ ))} +
+
+ + +
+

If your linen room, aged-care home or clinic has the same problem, come and say so.

+ Get in touch +
+
+ + + + ); +} diff --git a/app/(site)/acceptable-use/page.tsx b/app/(site)/acceptable-use/page.tsx new file mode 100644 index 0000000..8dbb562 --- /dev/null +++ b/app/(site)/acceptable-use/page.tsx @@ -0,0 +1,9 @@ +import LegalDoc from "@/components/LegalDoc"; +import { DOC_META } from "@/lib/legal"; + +const M = DOC_META["Acceptable Use"]; +export const metadata = { title: M.title, description: M.desc, alternates: { canonical: M.path } }; + +export default function Page() { + return ; +} diff --git a/app/(site)/contact/page.tsx b/app/(site)/contact/page.tsx new file mode 100644 index 0000000..2558dff --- /dev/null +++ b/app/(site)/contact/page.tsx @@ -0,0 +1,53 @@ +import Link from "next/link"; +import ContactForm from "@/components/ContactForm"; +import { CtaBand, PageHead, SPLIT_PAD, SiteNav, body, h3, kicker, small, wrap } from "@/components/site"; + +export const metadata = { + title: "Contact", + description: "Ask a question about ThreadCount, book a walkthrough, or raise a security review. One person answers.", + alternates: { canonical: "/contact" }, +}; + +export default function Contact() { + return ( + <> + + + +
+
+
+
+ +
+ +
+
+
Book a walkthrough
+

Twenty minutes, on real screens.

+

Bring your own questions and your own room. No slides unless you want them.

+ {/* No direction: below 900px this panel stacks under the form, so there is no left. */} +

Pick “Book a walkthrough” on the form and say when suits — I’ll work around your shift rather than the other way round.

+
+ +
+
Who replies
+

One person, the one who built it.

+

Usually within a working day. It’s maintained alongside a day job, so nights and weekends aren’t covered — Support sets out what that means honestly.

+
+
Prefer email? hello@threadcount.tech for questions, privacy@ for privacy requests, security@ for security reports.
+
+
+ Open the working demo + Read the FAQ first +
+
+
+
+
+
+ + + + ); +} diff --git a/app/(site)/data-security/page.tsx b/app/(site)/data-security/page.tsx new file mode 100644 index 0000000..32affc1 --- /dev/null +++ b/app/(site)/data-security/page.tsx @@ -0,0 +1,9 @@ +import LegalDoc from "@/components/LegalDoc"; +import { DOC_META } from "@/lib/legal"; + +const M = DOC_META["Data Security"]; +export const metadata = { title: M.title, description: M.desc, alternates: { canonical: M.path } }; + +export default function Page() { + return ; +} diff --git a/app/(site)/delete-account/page.tsx b/app/(site)/delete-account/page.tsx new file mode 100644 index 0000000..878da39 --- /dev/null +++ b/app/(site)/delete-account/page.tsx @@ -0,0 +1,162 @@ +import Link from "next/link"; +import { Band, CtaBand, PageHead, SiteNav, body, h2, h3, kicker } from "@/components/site"; + +/* Google Play requires a publicly reachable page that explains how to delete an account and what + happens to the data — reachable without signing in, which is why it lives on the marketing site + rather than inside the app. */ +export const metadata = { + title: "Delete your account", + description: "How to delete a ThreadCount account or just some of its data, what goes with it, and what is kept.", + alternates: { canonical: "/delete-account" }, +}; + +const KEPT = [ + ["Nothing, once the facility goes", "Deleting the last account deletes the facility outright. There is no archive copy and no soft-delete flag — the rows are gone from the database."], + ["Your name on past records, if colleagues remain", "If someone else still runs the facility, only your login is deleted. Issues, stocktakes and slips you recorded keep the name they were stamped with, because a stock record nobody signed isn’t an audit trail."], + ["Backups you downloaded", "A backup file you exported before deleting stays on your own computer. Delete it yourself if you don’t want it."], + ["Encrypted server backups, briefly", "Routine server backups roll off on their own schedule. Nothing is restored from them except to recover the whole server after a failure."], +]; + +const STEPS = [ + ["Sign in at threadcount.tech", "Use the account you want to delete. Deletion is done by the account holder — there is no form to fill in and nobody to email."], + ["Open Settings, then the Account tab", "It’s the last section on that tab, under your profile, password and users."], + ["Read which of the two cases you’re in", "The page tells you whether you’re the last person who can sign in. If you are, download a backup first — the link is right there."], + ["Confirm", "Enter your password. If deleting takes the facility with it, you also type the facility name exactly. Then it’s done immediately."], +]; + +const PARTIAL: [string, string][] = [ + ["Remove one person from the staff register", "Settings isn’t needed — open the staff member on the Staff Register and delete them. Their name stays on garments already issued, because a stock record nobody signed isn’t an audit trail."], + ["Wipe the activity, keep the setup", "Deletes every issue, return, order, delivery, pickup, stocktake, approval and photo, and resets stock adjustments. The catalogue, staff register, suppliers and departments stay, so the room can carry on from a clean ledger."], + ["Start fresh", "Deletes everything the facility has entered — catalogue, barcodes, staff, departments and all history — and leaves only the logins and the facility’s own settings. It’s the reset for a room that wants to begin again without losing its accounts."], + ["Export first, if you want a copy", "Settings › Data offers a complete JSON backup before you delete anything. It downloads to your own device and we keep no copy of it."], +]; + +export default function DeleteAccount() { + return ( + <> + + + + +
+

How to do it

+
    + {STEPS.map(([t, b], i) => ( +
  1. + {String(i + 1).padStart(2, "0")} + {t}{b} +
  2. + ))} +
+

+ Can't sign in? Use the contact form from the email address on the account and say which facility it belongs to. +

+
+
+ + +
+
If you use the staff app
+

Ask the linen room — it takes effect immediately

+

+ The steps above are for a linen-room coordinator. If you are a staff member who signs in + to ThreadCount Staff to see your own uniform, your account works differently, + because the record it attaches to belongs to the linen room rather than to you. +

+

+ Ask your uniform coordinator to remove your access. There is a button on your staff + record that does exactly that: it deletes your sign-in, and it ends every session you + have open straight away. You can be given a fresh code later if you change your mind. +

+

+ Your register entry and issue history stay behind, because the facility needs them for + its own stock and financial records — the same way your employer keeps a record of any + equipment issued to you. Deleting those is the facility's decision, not something + one sign-in controls. +

+

+ If you would rather not ask your coordinator, or you want a copy of what is held about + you first, write to privacy@threadcount.tech and we will route it through your + facility's privacy process. +

+
+
+ + +
+
The two cases
+

What gets deleted depends on whether anyone else is left

+
+
+

Other people can still sign in

+

+ Only your login is deleted. The facility, its catalogue, staff register, stock and history all stay, + because they belong to the linen room rather than to you. Your name stays on what you recorded. +

+
+
+

You are the last one

+

+ Deleting your account deletes the whole facility: every user, the catalogue and barcodes, the staff + register, every issue, return, order, delivery, stocktake, photo and signature. It happens immediately, + it cannot be undone, and support cannot get it back. Download a backup first if any of it matters. +

+
+
+
+
+ + +
+
Without closing your account
+

Deleting some data, but not all of it

+

+ You don't have to delete your account to remove data. An administrator can do any of these from + Settings › Data, and keep working afterwards: +

+
    + {PARTIAL.map(([t, b], i) => ( +
  1. + {String(i + 1).padStart(2, "0")} + {t}{b} +
  2. + ))} +
+

+ All of these happen immediately and can't be undone. None of them touches your login, so you stay + signed in and the facility keeps running. +

+
+
+ + +
+

What is kept, and for how long

+
+ {KEPT.map(([t, b]) => ( +
+ {t} +

{b}

+
+ ))} +
+

+ More detail on how data is held is in the privacy policy and{" "} + data security pages. +

+
+
+ + + + ); +} + +const wrapNarrow: React.CSSProperties = { maxWidth: 860 }; diff --git a/app/(site)/demo/page.tsx b/app/(site)/demo/page.tsx new file mode 100644 index 0000000..903c6f3 --- /dev/null +++ b/app/(site)/demo/page.tsx @@ -0,0 +1,66 @@ +import Link from "next/link"; +import { DEMO_FACILITY, DEMO_RESET_MINUTES } from "@/lib/demo"; +import { CtaBand, SiteNav } from "@/components/site"; +import { switches } from "@/lib/switches"; + +/* Rendered per request, unlike the rest of the site: the demo switch lives in the database now + * and can be flipped from the console, and a page cached for a day would keep offering "Enter as + * Admin" long after the entry endpoint had started answering 404. */ +export const dynamic = "force-dynamic"; + +export const metadata = { + title: "Try the working demo", + description: "Open a stocked, fictional hospital linen room and use ThreadCount as an Admin or an Issuer. The same screens run an aged-care home’s store or a clinic’s cupboard. No sign-up.", + alternates: { canonical: "/demo" }, +}; + +const CARDS = [ + { as: "admin", role: "Admin", who: "Alex Demo · Uniform Coordinator", body: "The coordinator’s view. Settings, the catalogue and prices, the staff register, suppliers, reorder levels, every report and the month-end pack." }, + { as: "issuer", role: "Issuer", who: "Sam Demo · Linen Room Assistant", body: "The counter view. Issue stock, record a manager’s approval, run a stocktake, receive a delivery and work through the pickup call list." }, +]; + +export default async function DemoPage({ searchParams }: { searchParams: Promise<{ signedin?: string }> }) { + const sp = await searchParams; + /* The same switch the entry endpoint honours (app/api/auth/demo/route.ts), read here too. + * + * With the demo out of service that endpoint answers a bare JSON 404, so a page that still + * offers "Enter as Admin" walks somebody weighing the product onto a white screen of raw JSON + * with no nav and no way back. Sign-ups are handled the same way — /auth hides the create-account + * form when sign-ups are closed rather than letting the button fail — and the demo has to go + * quiet at the same moment its endpoint does. */ + const { demoOpen } = await switches(); + return ( + <> + +
+
Working demo
+

{demoOpen ? <>Have a look around {DEMO_FACILITY}’s linen room. : <>The demo is closed for the moment.}

+

{demoOpen + ? "It is a made-up hospital, but everything in it is real: a stocked shelf, staff on the register, orders still open with the supplier, people waiting on a pickup, and three months of issues behind it. It could as easily be an aged-care home or a day surgery — the shelf, the counter and the screens are the same. Pick a role and have a look around. Nothing to sign up for." + : `${DEMO_FACILITY} is a made-up hospital anyone can walk into, and it has been taken out of service for a while. Nothing is wrong with ThreadCount itself, and a facility of your own is unaffected.`}

+ {sp.signedin === "1" &&
You’re signed in to your own facility. Go to your app, or sign out first to open the demo.
} + {demoOpen ? <> +
+ {CARDS.map((c, i) => ( +
+
{c.who}
+
Enter as {c.role}
+

{c.body}

+ Open the {c.role} view +
+ ))} +
+
+ How the demo works. Everyone shares the one demo facility, so you might spot someone else’s changes while you are in there. It goes back to the same starting point every {DEMO_RESET_MINUTES} minutes, so feel free to break things. Demo accounts can’t change passwords or users, or wipe the data. Everything else — issuing, ordering, receiving, stocktakes, reports, printing — behaves exactly as it would in your own facility. None of it is real hospital data. +
+ : ( +
+ There is nothing to open just now. The shared demo facility is off, so the two role buttons that normally sit here would only lead to a closed door. Until it is back, how it works walks through the same day at the counter, and the feature list covers what is in it. If you would rather be shown around, ask for a look. +
+ )} +
Want one of your own? Set up your facility — it takes about a minute, and your catalogue and staff register come in from CSV.
+
+ + + ); +} diff --git a/app/(site)/faq/page.tsx b/app/(site)/faq/page.tsx new file mode 100644 index 0000000..5f47974 --- /dev/null +++ b/app/(site)/faq/page.tsx @@ -0,0 +1,72 @@ +import Link from "next/link"; +import { CtaBand, PageHead, SiteNav, body, h3, kicker, small } from "@/components/site"; +import JsonLd from "@/components/JsonLd"; +import { faqPage, graph } from "@/lib/schema"; +import { PRICES } from "@/lib/plan"; +import { plansLive } from "@/lib/plans-live"; + +export const metadata = { + title: "Questions", + description: "Is it really free, is it only for hospitals, what does it run on, do we need scanners, how do nursing entitlements work, where is our data, and what if we stop using it.", + alternates: { canonical: "/faq" }, +}; + +/* [number, question, answer, plainAnswer?] + The fourth slot exists only for the answer whose JSX can’t be serialised into + structured data. Everything else marks up exactly the string on the page. */ +const faqs = (live: boolean): [string, string, React.ReactNode, string?][] => [ + live + ? ["01", "Is it really free?", `The software is. Run it on your own server and it costs nothing, with every feature. Hosted on threadcount.tech it is free for a room under ${PRICES.freeStaff} staff records, and $${PRICES.hostedAnnual.toLocaleString("en-AU")} a year for a facility past that — which pays for the hosting, the backups and a person who answers. It was built by a hospital uniform coordinator for their own room, and there’s no sales process attached to it.`] + : ["01", "Is it really free?", "Yes. No licence fee, no per-device charge, no per-user charge. It was built by a hospital uniform coordinator for their own room, and there’s no sales process attached to it."], + ["02", "Is it only for hospitals?", "No. It was built in a hospital linen room, and that is the setting it knows best, but the loop is the same anywhere uniforms go out from a shelf: an aged-care home, a day surgery, a dental or GP practice, allied health, pathology, community and home care. One thing to know up front — the screens say ward and linen room, because that is the vocabulary it was written in."], + ["03", "What does it run on?", "Any browser, on the phones, tablets and computers the room already has, and nothing needs installing to start. Camera scanning is the one part that depends on which browser: Chrome and Edge read a barcode straight from the camera, on Android and on a computer. Safari on an iPhone or iPad, and Firefox anywhere, cannot — there you type the code or use a USB scanner."], + ["04", "Do we need barcode scanners?", "No, though a USB scanner is the fastest thing at a counter. It works with the supplier barcodes already printed on the garment, read by that scanner or by the phone camera in a browser that supports it. If a code isn’t recognised, tell it once what the garment is and it stays bound."], + ["05", "Can more than one person use it at a time?", "Yes. Access is per person, not per device, so the counter and the ward, wing or clinic can all be working without anyone waiting for a licence to free up."], + ["06", "How does it handle nursing entitlements?", "Through the FTE table on the signed order form. The hours someone works propose their starting kit, and their manager can sign for more than it proposes. Nobody holds more than six sets at a time — or whatever ceiling you set — nurses included — past that it takes a hand-in or a coordinator’s recorded override. You choose which staff groups use the table; the others start on a fixed kit, or have a manager approve each set."], + ["07", "What happens when the box arrives short?", "Receive the lines that came, and the shortfall splits automatically to a back order against the same supplier reference. Nothing has to be re-keyed."], + ["08", "Will finance accept the export?", "The journal comes out as one debit line per cost centre against your GL account, in CSV. Most finance teams upload it directly; if yours needs a different layout, say so."], + ["09", "Where is our data held, and who can see it?", <>Only the people you invite can see your room’s records, and nothing is pooled with another facility. Hosting, backup and retention should be agreed with your information security team before real staff data goes in — there’s more in Security & data., + "Only the people you invite can see your room’s records, and nothing is pooled with another facility. Hosting, backup and retention should be agreed with your information security team before real staff data goes in."], + ["10", "What if we want to stop using it?", "Every report and register exports as CSV. An admin can also download the whole facility as one backup file, every issue, order and count included. Take the data and go: there’s nothing to cancel and nothing held back."], +]; + +export default async function Faq() { + const FAQS = faqs(await plansLive()); + return ( + <> + + typeof a === "string" || plain) + .map(([, q, a, plain]) => ({ q, a: plain ?? (a as string) })), + ), + )} + /> + + + {/* Deliberately narrower than the rest of the site. */} +
+
+ {FAQS.map(([n, q, a]) => ( +
+
{n}
+
+

{q}

+

{a}

+
+
+ ))} +
+
Something not covered here?
+

Ask it directly.

+
Replies come from the person who built the thing, so the answer will be straight.
+ Ask a question +
+
+
+ + + + ); +} diff --git a/app/(site)/features/page.tsx b/app/(site)/features/page.tsx new file mode 100644 index 0000000..30dac7d --- /dev/null +++ b/app/(site)/features/page.tsx @@ -0,0 +1,95 @@ +import Link from "next/link"; +import { Band, CtaBand, PageHead, SiteNav, body, h2, h3, kicker, small } from "@/components/site"; + +export const metadata = { + title: "Features", + description: "Nine things a linen room does every day, in a hospital, an aged-care home or a clinic: scanning, the pickup call list, supplier orders, stocktakes, allowances set per staff group, roles, staff records, catalogue and suppliers.", + alternates: { canonical: "/features" }, +}; + +const FEATURES = [ + { n: "02", t: "The pickup call list", b: "Staff orders that have arrived queue by days waiting. Ring them, mark them contacted, print the collection slip, tick them off when they take it." }, + { n: "03", t: "Orders you can trace back", b: "Supplier order number, invoice, tracking, what actually turned up on each line, a purchase order you can print, and a back order raised for whatever the box was short." }, + { n: "04", t: "Stocktakes that stay filed", b: "Scan to count, with a blind mode when you would rather not see the system figure while you count. Variances in dollars as you go, and each count kept with the date, who counted, and every line that moved." }, + { n: "05", t: "Three routes, one ceiling", b: "Name your own staff groups and give each one a route. On the FTE table, the hours someone works propose their starting kit and a manager can sign for more. On a starting kit, they get a fixed number of sets on day one and more as they need them. On manager approval, a manager signs for each set. Every route stops at six sets held at any one time, unless you set a different figure. Past it takes a hand-in or a coordinator’s recorded override." }, + { n: "06", t: "Two roles, and only two", b: "Admins look after settings, staff, the catalogue, suppliers and prices. Issuers issue, count and receive. Nobody edits a price or a past issue by accident, because Issuers simply can’t." }, + { n: "07", t: "Staff records with size history", b: "Every staff member carries their ward or clinic, cost centre, staff group and the sizes they were last issued, so the second visit takes seconds." }, + { n: "08", t: "Catalogue and suppliers together", b: "Garments, sizes, unit costs and supplier codes live in one place. Change a price once and every future issue values correctly — past issues keep the price they were recorded at." }, + { n: "09", t: "Nothing to install", b: "It runs in the browser on the devices the room already has. Chrome and Edge — on Android and on a computer — read garment barcodes straight from the camera with nothing extra installed. On an iPhone or iPad, and in Firefox, the code is typed or read with a USB scanner." }, +]; + +export default function Features() { + return ( + <> + + Nine things the room actually does every day, and nothing it doesn’t. No modules to buy, and no setting up before you can issue a scrub top.

} + /> + + + {/* Row 01 is the emphasised one — bigger numeral, its own kicker. */} +
+
01
+
+
The difference
+

Scan-first, at the counter and out on the floor

+
+

It reads the barcodes already printed on the garment labels, using a USB scanner at the counter, or your phone camera out on a ward, a wing or a clinic room in a browser that can read one — Chrome and Edge can; Safari on an iPhone or iPad cannot. Scan something it doesn’t know and you tell it once what it is; after that it just knows. Issuing, counting and receiving are all the same scan.

+
+ + {FEATURES.map((f) => ( +
+
{f.n}
+

{f.t}

+

{f.b}

+
+ ))} +
+ + + {/* Slips band with the recreated collection slip */} + +
+
+

Slips come out filled in.

+

Collection slips, ward delivery notes and the credit slips against a manager’s approval print with the staff member, ward, sets and order reference already on them. Whoever takes the round can sign the paper, or sign on screen when you hand it over; an on-screen signature is kept with that delivery and deleted with it.

+
+
+
+ Collection slip — 04 Sep 2026Print +
+
+ {[["Staff", "M. Whitfield, RN"], ["Ward", "3A · cost centre RGH-3010"], ["Approved sets", "3 of 5 · manager’s approval on file"]].map(([k, v]) => ( +
{k}{v}
+ ))} +
+ {[["RN Active Scrub Top · M ×2", "From stock", false], ["Elastic Waist Scrub Pant · M ×2", "From stock", false], ["Softshell Jacket · M ×1", "Order in", true]].map(([n, s, red]) => ( +
{n}{s}
+ ))} +
+
Signature ______________________
+
+
+
+
+ + +
+
+

See it running with demonstration data.

+
A stocked shelf, staff on the register, orders open and three months of history behind it.
+
+
+ Open the working demo + How it works +
+
+
+ + + + ); +} diff --git a/app/(site)/getting-started/page.tsx b/app/(site)/getting-started/page.tsx new file mode 100644 index 0000000..dfbaa8c --- /dev/null +++ b/app/(site)/getting-started/page.tsx @@ -0,0 +1,80 @@ +import Link from "next/link"; +import { Band, CtaBand, PageHead, SiteNav, body, h2, h3, kicker } from "@/components/site"; + +export const metadata = { + title: "Getting started", + description: "Five steps from opening the demo to issuing at the counter, with an honest time estimate against each one. The same five whether the shelf is a linen room or a store cupboard.", + alternates: { canonical: "/getting-started" }, +}; + +const STEPS = [ + { n: "1", t: "Open the demo and have a look", b: "Everything works on demonstration data. Issue a set, receive a box, run a report. Nothing you do here touches a real record.", time: "20 minutes" }, + { n: "2", t: "Load the catalogue", b: "Garments, sizes, unit costs and supplier codes. This is the one piece worth doing carefully, because every report values from it.", time: "An hour" }, + { n: "3", t: "Load the staff list", b: "Name your staff groups in Settings and choose each one’s route first, then load the list: name, department, cost centre and group. Sizes fill in as people are issued, so don’t hold up the start waiting for them.", time: "An hour" }, + { n: "4", t: "Count what’s on the shelf", b: "Run a stocktake as your opening balance. From that point on-hand is live and reorder flags start working.", time: "An afternoon" }, + { n: "5", t: "Start issuing", b: "That’s it. The first replenishment order builds itself from the first day’s movement.", time: "Same day" }, +]; + +const READY = [ + "The garment list with sizes and current unit costs", + "Supplier names, codes and order contacts", + "The staff list with departments and cost centres", + "Your GL account for uniform spend", + "Whoever signs off entitlements for each team", +]; + +export default function GettingStarted() { + return ( + <> + + + + + {STEPS.map((s) => ( +
+
{s.n}
+
+

{s.t}

+

{s.b}

+
+
+
About
+
{s.time}
+
+
+ ))} +
+ + + +

What to have ready.

+

None of it is hard to find. Most linen rooms and store cupboards already have all five in a workbook somewhere.

+
+ {READY.map((r) => ( +
+ {r} +
+ ))} +
+
+ + +
+
One thing first
+

Talk to information security before real staff data goes in.

+

Try the demo with demonstration data as long as you like. The moment you want your own staff list in it, hosting, backup and retention should be agreed with your facility.

+
+ Open the working demo + Security & data +
+
+
+ + + + ); +} diff --git a/app/(site)/guides/cost-centre-reporting/page.tsx b/app/(site)/guides/cost-centre-reporting/page.tsx new file mode 100644 index 0000000..2797a22 --- /dev/null +++ b/app/(site)/guides/cost-centre-reporting/page.tsx @@ -0,0 +1,109 @@ +import Link from "next/link"; +import { Band, body, small } from "@/components/site"; +import { GuideFoot, GuideHead, GuideMeta, Pitfalls, Steps, type Step } from "@/components/guide"; + +export const metadata = { + title: "Charging uniforms to the right cost centre", + description: + "How to attribute uniform spend to the ward, wing or clinic that incurred it: what to capture at the counter, how to value an issue, and what finance needs in the journal before they will accept it.", + alternates: { canonical: "/guides/cost-centre-reporting" }, +}; + +const STEPS: Step[] = [ + { + n: "01", + h: "Attribute at the moment of issue, not at month end", + p: <>The only person who reliably knows which ward, wing or clinic a garment is for is the person handing it over. Reconstructing that from invoices four weeks later is guesswork, and it is the reason most linen rooms end up charging everything to one central code. Capture the cost centre when the garment leaves the counter and the month-end job becomes an export rather than an investigation., + }, + { + n: "02", + h: "Hang the cost centre off the person, not the transaction", + p: <>Asking “which cost centre?” at every issue is a question that will be answered wrong under pressure. Put the ward and cost centre on the staff record, so the issue inherits it. The counter stays fast, and corrections happen once on the register rather than repeatedly at the counter., + }, + { + n: "03", + h: "Decide what an issue is worth", + p: <>Unit cost is the defensible answer: what you paid the supplier for that garment, per unit. Avoid apportioning freight and avoid an average across sizes if your sizes genuinely differ in price. The figure needs to be one you can explain in a sentence, because at some point someone will ask you to., + }, + { + n: "04", + h: "Decide when the charge lands", + p: <>There are two honest answers and you must pick one: at purchase, or at issue. Charging at purchase makes the linen room’s budget lumpy and makes wards indifferent to what they take. Charging at issue is what most facilities want — the ward or clinic feels the cost of its own consumption — but it means your stock on hand is an asset carried by the linen room until it moves. Pick one, write it down, and don’t quietly change it mid-year., + }, + { + n: "05", + h: "Produce one line per cost centre", + p: <>Finance does not want your transaction list. They want a journal: one debit line per cost centre, one credit to the GL account the stock was bought against, for a stated period, totalling to a number that matches. Give them exactly that as CSV and the upload takes minutes. Give them a spreadsheet of every issue and it will sit in an inbox., + }, + { + n: "06", + h: "Keep the detail behind the summary", + p: <>The summary is what gets uploaded; the detail is what settles the argument when a ward manager queries their figure. You want to be able to go from “Ward 3A, $1,840” to the individual issues behind it without rebuilding anything. If your summary can’t be drilled into, expect to spend the following week defending it., + }, +]; + +const PITFALLS: [string, string][] = [ + ["One catch-all cost centre", "Everything charged centrally means no ward, wing or clinic ever sees the cost of its own uniform consumption, so nothing ever changes."], + ["Cost centres that only exist in someone’s head", "If the mapping from ward or team to code lives in memory, it leaves when that person does. Put it on the record."], + ["Charging at purchase and at issue", "Double-counting is the fastest way to lose finance’s trust, and it is easy to do accidentally when the method changes mid-year."], + ["Retail price instead of unit cost", "There is no margin here. Anything other than what you paid invites a question you cannot answer."], + ["No period stamped on the export", "A journal without an unambiguous date range cannot be reconciled and will be sent back."], + ["Rounding per line", "Round the total, not each line, or the journal won’t balance against the invoice and someone will spend an afternoon on eleven cents."], +]; + +export default function Page() { + return ( + <> + + + + + + + + +
+ What finance sends back +
+ +
+ + +

+ Doing this in ThreadCount +

+

+ Ward and cost centre sit on the staff record, so every issue values itself at unit cost + against the right code without anyone being asked at the counter. The journal exports as + one debit line per cost centre for the month you choose, and each line opens the issues + behind it — who, what, which size, what it cost — when someone queries their + number. +

+

+ If your finance team needs a different layout, that is usually a small change — say what + they need. +

+
+ See the reporting + Ask about a layout +
+
+ + + + ); +} diff --git a/app/(site)/guides/manager-approvals/page.tsx b/app/(site)/guides/manager-approvals/page.tsx new file mode 100644 index 0000000..75b44fd --- /dev/null +++ b/app/(site)/guides/manager-approvals/page.tsx @@ -0,0 +1,113 @@ +import Link from "next/link"; +import { Band, body, small } from "@/components/site"; +import { GuideFoot, GuideHead, GuideMeta, Pitfalls, Steps, type Step } from "@/components/guide"; + +export const metadata = { + title: "Staff uniform entitlements and manager approvals", + description: + "How to run a uniform entitlement that holds, on a ward or in an aged-care home: what an approval needs to record, how to handle part-time and agency staff, what to do when someone asks for more.", + alternates: { canonical: "/guides/manager-approvals" }, +}; + +const STEPS: Step[] = [ + { + n: "01", + h: "Write the entitlement down before you need it", + p: <>An entitlement that exists only as custom is one you will renegotiate at the counter, individually, forever. Agree the number of sets per role, put it somewhere both the linen room and the wards, homes or clinics it serves can see, and date it. The specific number matters far less than everyone knowing what it is., + }, + { + n: "02", + h: "Prorate honestly, or don’t prorate at all", + p: <>Part-time staff are where entitlement schemes get messy. A nurse at 0.4 FTE — or a care worker on two shifts a week, or a dental assistant covering Fridays — still needs enough sets to get through a week without doing laundry nightly, so a straight multiplication produces something unworkable and quietly ignored. Either set a floor below which nobody drops, or don’t prorate and say so. What you cannot do is have a rule on paper that the counter overrides in practice, because then there is no rule., + }, + { + n: "03", + h: "Make the approval carry its own evidence", + p: <>An approval should record who approved it, what they approved, for whom, and when. The approver is the staff member’s own manager — the ward, clinic or team manager. “The manager said it was fine” is not an approval, and it is exactly what you will be holding when someone asks why a ward went over budget. Name, number of sets, FTE if it bears on the calculation, and a date., + }, + { + n: "04", + h: "Separate the entitlement from the issue", + p: <>Someone can be entitled to five sets and take three today. Track the entitlement and the issues against it as different things, so the balance is visible. Otherwise the only way to know what someone is still owed is to add up their history, and nobody does that at a counter with three people waiting., + }, + { + n: "05", + h: "Give them the balance in writing", + p: <>When a staff member takes less than their entitlement — because the size isn’t there, or they only wanted two — hand over something that records the balance. A credit slip stops the same conversation happening again in a fortnight with a different person at the counter, and it stops the quiet inflation that happens when nobody can remember what was already given., + }, + { + n: "06", + h: "Decide what happens when someone asks for more", + p: <>They will, and often for good reason: a garment condemned after a spill, a size change, a genuine increase in hours. Have a route that isn’t “no” and isn’t “yes, quietly”. An over-entitlement issue with a recorded approval keeps the room helpful and the numbers honest at the same time., + }, + { + n: "07", + h: "Handle agency and students explicitly", + p: <>Short-term staff are the fastest route to unreturned stock, because there is often nobody to ask afterwards. Decide in advance whether they get issued at all, and if so whether it is a loan against a return date. Whatever you decide, decide it once rather than at the counter., + }, +]; + +const PITFALLS: [string, string][] = [ + ["Verbal approvals", "Nothing to show and nothing to reconcile. The first budget query will land on the linen room, not on the person who approved it."], + ["Entitlement with no start date", "Without a date you cannot tell an annual renewal from a duplicate issue, and long-serving staff quietly accumulate."], + ["Prorating below a workable minimum", "A rule that makes it impossible to get through a week will be broken at the counter, and then no rule is being followed at all."], + ["No record of what’s outstanding", "If the balance isn’t visible, the safe answer at the counter is always to hand over another set."], + ["Treating a size change as a new issue", "It doubles the person’s apparent consumption and hides a real signal about how you’re buying sizes."], + ["No closing routine for leavers", "The entitlement was correct; nobody asked for the garments back. This is the single largest source of unreturned stock in most rooms."], +]; + +export default function Page() { + return ( + <> + + + + + + + + +
+ Where entitlements come apart +
+ +
+ + +

+ Doing this in ThreadCount +

+

+ Entitlement sits on the staff record and the balance is on screen at the counter, so the + person handing over garments can see what is still owed without adding anything up. An + approval records the approver, the sets approved and the FTE it was based on. Take less + than the entitlement and a credit slip prints for the balance. +

+

+ Going over is allowed — it just asks for the approval first, which is the only difference + between a helpful exception and an unexplained one. +

+
+ Open the demo + How issuing works +
+
+ + + + ); +} diff --git a/app/(site)/guides/num-approvals/page.tsx b/app/(site)/guides/num-approvals/page.tsx new file mode 100644 index 0000000..b9ef90c --- /dev/null +++ b/app/(site)/guides/num-approvals/page.tsx @@ -0,0 +1,10 @@ +import { permanentRedirect } from "next/navigation"; + +// The guide moved to /guides/manager-approvals once the approval stopped being a nursing-only +// idea. This is a 308 rather than the 307 /product uses: that path was live only briefly before +// launch, whereas this one has been in the sitemap, in cold emails and in search results for +// months. The move is permanent, so say so — search engines hand the ranking to the new URL and +// browsers stop asking for the old one. +export default function NumApprovalsRedirect() { + permanentRedirect("/guides/manager-approvals"); +} diff --git a/app/(site)/guides/page.tsx b/app/(site)/guides/page.tsx new file mode 100644 index 0000000..5799ded --- /dev/null +++ b/app/(site)/guides/page.tsx @@ -0,0 +1,74 @@ +import Link from "next/link"; +import { Band, CtaBand, PageHead, SiteNav, body, small } from "@/components/site"; + +export const metadata = { + title: "Guides", + description: + "Notes on running a linen room — hospital, aged care or clinic: stocktakes that produce a usable number, where uniforms actually go, charging spend to the right ward, and entitlements that hold up.", + alternates: { canonical: "/guides" }, +}; + +/* Written for the person doing the job, not for the person buying software. Each of these is + useful with a clipboard and no product at all — which is the only reason anyone would link to + them. */ +export const GUIDES: [string, string, string][] = [ + [ + "/guides/uniform-stocktake", + "How to run a uniform stocktake", + "What to count, how to split a room into shelves, what to do about garments at the laundry, and how to record a variance so the number still means something a month later.", + ], + [ + "/guides/uniform-loss", + "Where the uniforms actually go", + "Loss is rarely theft. Leavers nobody closed off, sizes swapped at the shelf, laundry that never came back, damage never written off — and how to tell them apart.", + ], + [ + "/guides/cost-centre-reporting", + "Charging uniforms to the right cost centre", + "Capturing the cost centre at the counter, valuing an issue defensibly, and producing a journal finance will actually accept.", + ], + [ + "/guides/manager-approvals", + "Entitlements and manager approvals", + "Writing the entitlement down, prorating part-time hours honestly, recording an approval that carries its own evidence, and what to do when someone asks for more.", + ], +]; + +export default function Guides() { + return ( + <> + + + + + {GUIDES.map(([href, title, blurb], i) => ( + +
+ {String(i + 1).padStart(2, "0")} +
+
+

+ {title} +

+

{blurb}

+
+ + ))} +

+ Something missing that you had to work out the hard way?{" "} + Tell us and it will get written up. +

+
+ + + + ); +} diff --git a/app/(site)/guides/uniform-loss/page.tsx b/app/(site)/guides/uniform-loss/page.tsx new file mode 100644 index 0000000..7fb7a4d --- /dev/null +++ b/app/(site)/guides/uniform-loss/page.tsx @@ -0,0 +1,104 @@ +import Link from "next/link"; +import { Band, body, small } from "@/components/site"; +import { GuideFoot, GuideHead, GuideMeta, Pitfalls, Steps, type Step } from "@/components/guide"; + +export const metadata = { + title: "Where hospital and aged-care uniforms actually go", + description: + "Uniform loss is rarely theft. The honest causes — starters who never return a set, sizes swapped at the shelf, laundry that never comes back, leavers nobody closed off — and what to record so you can tell them apart.", + alternates: { canonical: "/guides/uniform-loss" }, +}; + +const STEPS: Step[] = [ + { + n: "01", + h: "Stop calling it shrinkage", + p: <>Borrowing the retail word imports the retail assumption, which is that stock is being stolen. In a linen room that is almost never the main cause, and starting there sours every conversation you need to have with the ward, wing or practice manager. What you have is unreturned stock, and most of it left through a door you can name., + }, + { + n: "02", + h: "Leavers who were never closed off", + p: <>The single largest cause in most rooms. Someone is issued three sets on their first day, works for two years, resigns, and nobody tells the linen room. The sets are gone and the record still shows them holding stock. This is a process gap, not a stock problem: what you need is a reliable signal from HR, the ward or the practice when someone leaves, and a routine for closing the record and asking for the garments back while there is still someone to ask., + }, + { + n: "03", + h: "Size changes that were never recorded", + p: <>A staff member takes a medium, finds it tight, and swaps it at the shelf for a large. Two garments have now moved and the record shows one. Do it a hundred times a year and your per-size figures drift far enough that ordering becomes guesswork. The fix is cheap: make the exchange a recorded action — the old size back, the new size out, in one movement — rather than something people do quietly because the proper route is slow., + }, + { + n: "04", + h: "Laundry that never came back", + p: <>Garments sent for washing are stock in transit, and transit is where things vanish without anyone noticing, because nobody owns the gap. If you send in bulk and receive in bulk, count both ends. A persistent difference between what went and what returned is a conversation with the laundry provider, and it is a conversation you can only have with numbers., + }, + { + n: "05", + h: "Damage that was never written off", + p: <>Torn, stained beyond use, or condemned on infection-control grounds. This is legitimate loss and should be recorded as such. If it isn’t, it lands in the same bucket as unreturned stock and makes your genuine problem look worse than it is — which costs you credibility exactly when you are asking for something., + }, + { + n: "06", + h: "Then, and only then, the small remainder", + p: <>When the four causes above are recorded properly, what is left is usually a modest number. That remainder is worth attention, and it is now attention you can direct, because you know it is not laundry, not leavers, not swaps and not damage., + }, +]; + +const PITFALLS: [string, string][] = [ + ["Issuing to a ward instead of a person", "Stock issued to “Ward 3A” can never be returned by anyone, because nobody holds it. Issue to a named person or accept that it is a write-off."], + ["No opening entitlement", "If nobody agreed how many sets a role gets, there is no such thing as too many, and every request is reasonable."], + ["Counting loss only in units", "A hundred lost XS gowns and a hundred lost theatre sets are the same number and very different money. Value it, or the case for change won’t land."], + ["Chasing individuals first", "Going after named staff before fixing the leaver process makes the room unpopular and recovers very little. Fix the doors before the people."], + ["Annual review only", "A number produced once a year is a post-mortem. Monthly, it is a control, and you still remember what happened."], + ["No agreed definition of loss", "If finance, the ward and the linen room each mean something different by it, the meeting is about definitions rather than uniforms."], +]; + +export default function Page() { + return ( + <> + + + + + + + + +
+ What makes it worse +
+ +
+ + +

+ Doing this in ThreadCount +

+

+ Every issue is against a named person, so a leaver’s outstanding sets are a list + rather than a mystery. A size change is one recorded exchange rather than two movements + nobody made. Returns carry a condition, so damage is written off as damage. What remains + unexplained stays visible instead of being averaged away. +

+
+ Open the demo + What the reports show +
+
+ + + + ); +} diff --git a/app/(site)/guides/uniform-stocktake/page.tsx b/app/(site)/guides/uniform-stocktake/page.tsx new file mode 100644 index 0000000..e00a573 --- /dev/null +++ b/app/(site)/guides/uniform-stocktake/page.tsx @@ -0,0 +1,113 @@ +import Link from "next/link"; +import { Band, body, small } from "@/components/site"; +import { GuideFoot, GuideHead, GuideMeta, Pitfalls, Steps, type Step } from "@/components/guide"; + +export const metadata = { + title: "How to run a uniform stocktake in a hospital, aged-care home or clinic", + description: + "A count that survives scrutiny in a hospital linen room or an aged-care store: what to count, how to split a room into shelves, what to do about garments at the laundry, and how to record a variance.", + alternates: { canonical: "/guides/uniform-stocktake" }, +}; + +const STEPS: Step[] = [ + { + n: "01", + h: "Decide what a count is for before you start", + p: <>A stocktake answers one of two questions, and they need different counts. Either you are correcting the on-hand figure so ordering stops going wrong, or you are producing a stock valuation someone in finance will sign. The first can be done shelf by shelf over a fortnight. The second has to be a single point in time with issuing stopped, or the number is meaningless. Most linen rooms need the first far more often than the second, and get into trouble by attempting the second casually., + }, + { + n: "02", + h: "Freeze movement, or record it", + p: <>The commonest way a count goes wrong is that garments move while it is happening. If you can, stop issuing for the duration. If you cannot — and on a ward or in an aged-care home at shift change you usually cannot — then write down every issue and every return made during the count, and apply them afterwards. An unrecorded issue mid-count looks exactly like a loss, and you will spend an afternoon chasing it., + }, + { + n: "03", + h: "Break the room into shelves, not into products", + p: <>Count by location, not by catalogue. A person standing at a shelf can count everything on it accurately; the same person asked to count “all scrub tops” has to walk the whole room, and will miss the bay by the door. Give every shelf and bay a name, count it as a unit, and record the count against that location. It also means two people can count different parts of the room at once without colliding., + }, + { + n: "04", + h: "Count by size, always", + p: <>“Forty scrub tops” is not a usable number. Forty tops that are all XXL is a shortage of every other size wearing a healthy total. Every count line should be item plus size, because that is the level at which you run out, and the level at which you reorder., + }, + { + n: "05", + h: "Account for what isn’t on the shelf", + p: <>At any moment a large share of your stock is legitimately elsewhere: at the laundry, issued to staff, in a delivery not yet put away, or set aside for a starter pack. None of that is missing, but if your count only covers shelves then all of it reads as a loss. Decide in advance how each of those is treated and be consistent — the usual approach is to count shelves only, and compare against expected shelf stock rather than against everything you have ever bought., + }, + { + n: "06", + h: "Write down why, not just what", + p: <>A variance with no reason beside it is worthless three weeks later. “Twelve fewer size M tops than expected” tells you nothing; “twelve fewer, ward reported a bin sent to laundry unlogged” tells you where to look next time. Make a reason mandatory past a threshold you choose — five is a sensible starting point — so small counting noise passes without ceremony and real gaps get explained while the explanation is still known., + }, + { + n: "07", + h: "Apply it, then look at the pattern", + p: <>Correcting the on-hand figure is the easy half. The value is in what repeats: the same size short every count, the same shelf always over, a variance that appears only after a particular roster. One count is an anecdote. Four counts with reasons attached is an argument you can take to a manager., + }, +]; + +const PITFALLS: [string, string][] = [ + ["Counting into a spreadsheet nobody reconciles", "A count that never gets applied to the on-hand figure changes nothing. If the spreadsheet is where it ends, ordering carries on from the same wrong number."], + ["Recounting only the lines that look wrong", "Recounting a shortfall and accepting every surplus quietly biases the result. Recount by shelf, not by whether you liked the answer."], + ["Treating the laundry as loss", "Garments in the wash are stock. Counted as missing, they justify orders you do not need, and the surplus arrives a fortnight later."], + ["One person counting their own room", "Not because anyone is dishonest — because you see what you expect. Where the count feeds a valuation, have someone else count at least a sample."], + ["No date on the count", "A number without the moment it was true cannot be reconciled against anything. Record when the count was taken, not when it was typed up."], + ["Stopping at the total", "The total is the least useful figure in a stocktake. The per-size variance is the one that changes what you order on Monday."], +]; + +export default function Page() { + return ( + <> + + + + + + + + +
+ Where counts go wrong +
+ +
+ + +

+ Doing this in ThreadCount +

+

+ Counts are taken by location, on a phone, scanning each garment. The expected figure stays + on screen as you go, the tally survives putting the phone down mid-shelf, and a gap past + your chosen threshold asks for a reason before it will commit. Nothing is applied to your + on-hand figures until you commit the count. +

+

+ None of which you need in order to follow the steps above — a clipboard and a consistent + method will do. It is just faster when the expected number is already in your hand. +

+
+ Open the demo + What else it does +
+
+ + + + ); +} diff --git a/app/(site)/how-it-works/page.tsx b/app/(site)/how-it-works/page.tsx new file mode 100644 index 0000000..729ed0b --- /dev/null +++ b/app/(site)/how-it-works/page.tsx @@ -0,0 +1,87 @@ +import Link from "next/link"; +import { Band, CtaBand, PageHead, SiteNav, body, h2, h3, kicker, small } from "@/components/site"; + +export const metadata = { + title: "How it works", + description: "Issue, replenish, receive — the three steps a linen room runs on, in a hospital, an aged-care home or a clinic, and what a morning at the counter actually looks like.", + alternates: { canonical: "/how-it-works" }, +}; + +const STEPS = [ + { n: "1", t: "Issue", b: "Scan the garment, pick the staff member, record it. Their ward or wing, cost centre, allowance route and last-issued sizes are already on the record, so a repeat visit takes seconds.", h: "What happens behind it", d: ["Nobody goes past six sets held without a recorded override", "A manager’s signed approval draws down as sets go over", "A credit slip prints for the rest of what was signed for", "The issue values at unit cost against the ward or clinic"] }, + { n: "2", t: "Replenish", b: "Every shelf issue feeds a draft supplier order, grouped by supplier. Review it, add the supplier’s order number, send. Nothing you issue goes unreplaced.", h: "What you get", d: ["A draft built from real movement, not guesswork", "Reorder flags before a size runs out", "A printable purchase order with your codes", "Order number, invoice and tracking held together"] }, + { n: "3", t: "Receive", b: "Tick lines off the box as they arrive. Price-check against the order, then send each line to the shelf or to a staff pickup.", h: "And when the box is short", d: ["Shorts split to a back order automatically", "Staff lines join the pickup call list", "Shelf lines update on-hand immediately", "The variance against the invoice is recorded"] }, +]; + +const DAY = [ + ["07:40", "Open the dashboard. Three orders open, one overdue at six days, seven sizes at or below reorder."], + ["08:05", "Two nurses at the counter. Scan, pick, issue — both walk away with their sets and a printed slip."], + ["09:30", "A box arrives. Receive it line by line, one item short; the back order writes itself."], + ["11:00", "Work the pickup call list — two wards and the day-surgery unit. Mark contacted, print collection slips for the ones coming down."], + ["14:15", "Review the draft replenishment order, add the supplier’s reference, send."], + ["16:20", "Month end approaching: run the cost centre report and the journal CSV, ready for finance."], +]; + +export default function HowItWorks() { + return ( + <> + + + + + {STEPS.map((s) => ( +
+
+
{s.n}
+
+
+
+

{s.t}

+

{s.b}

+
+
+
{s.h}
+
+ {s.d.map((d) => ( +
+ {d} +
+ ))} +
+
+
+ ))} +
+ + + {/* Full-bleed photo band */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ + +
A morning at the counter
+

What an ordinary Tuesday looks like.

+
+ {DAY.map(([t, w]) => ( +
+
{t}
+
{w}
+
+ ))} +
+
+ Open the working demo + Try the same flow with demonstration data. +
+
+ + + + ); +} diff --git a/app/(site)/layout.tsx b/app/(site)/layout.tsx new file mode 100644 index 0000000..898e688 --- /dev/null +++ b/app/(site)/layout.tsx @@ -0,0 +1,29 @@ +import { SiteFooter } from "@/components/site"; +import Analytics from "@/components/Analytics"; +import JsonLd from "@/components/JsonLd"; +import { graph, organization, website } from "@/lib/schema"; + +// Route group: no URL segment. Wraps every public marketing page in the same shell so the nav and +// footer are defined once. The nav itself is rendered per page so it can mark the active link. + +// These pages are prerendered. Two things in them move on their own: the footer's copyright year, +// and — once, on the day plans go live — the price on every page that states one (lib/plans-live.ts). +// A minute keeps the pages served from the prerender for every reader while letting the console's +// plans switch reach the site without a deploy. +export const revalidate = 60; +export default function SiteLayout({ children }: { children: React.ReactNode }) { + return ( +
+ {/* The link has to be the first focusable thing on the page, so it lives here rather than in + SiteNav. Its target is the page heading — PageHead carries id="content" — because each + page renders its own nav inside `children` to mark the active link, so a target on this + wrapper would land above the nav and skip nothing. */} + Skip to content +
{children}
+ + + {/* Who this is and what site it is. Stated once, on the shell every public page uses. */} + +
+ ); +} diff --git a/app/(site)/legal-about/page.tsx b/app/(site)/legal-about/page.tsx new file mode 100644 index 0000000..f757051 --- /dev/null +++ b/app/(site)/legal-about/page.tsx @@ -0,0 +1,9 @@ +import LegalDoc from "@/components/LegalDoc"; +import { DOC_META } from "@/lib/legal"; + +const M = DOC_META["About & Contact"]; +export const metadata = { title: M.title, description: M.desc, alternates: { canonical: M.path } }; + +export default function Page() { + return ; +} diff --git a/app/(site)/page.tsx b/app/(site)/page.tsx new file mode 100644 index 0000000..91dc2ae --- /dev/null +++ b/app/(site)/page.tsx @@ -0,0 +1,189 @@ +import Link from "next/link"; +import JsonLd from "@/components/JsonLd"; +import { graph, softwareApplication } from "@/lib/schema"; +import { Band, CtaBand, SiteNav, body, h2, kicker, small, wrap } from "@/components/site"; +import { PRICES } from "@/lib/plan"; +import { plansLive } from "@/lib/plans-live"; +import { COMMUNITY } from "@/lib/edition"; +import { redirect } from "next/navigation"; + +export const metadata = { alternates: { canonical: "/" } }; + +const CARDS = [ + { k: "Know", t: "What’s on the shelf", b: "On-hand by size, updated as you issue, and flagged before a size runs out on you." }, + { k: "Prove", t: "Where it went", b: "Every garment against the staff member who took it, dated, on a slip they signed." }, + { k: "Charge", t: "The right ward or clinic", b: "Issues carry the wearer’s cost centre at the price you paid, so the journal is already written." }, +]; + +const LOOP = [ + { n: "1", t: "Issue", b: "Scan the garment, pick the staff member, done. Their ward or clinic, cost centre and last sizes are already there, so a repeat visit takes seconds.", pad: 0 }, + { n: "2", t: "Replenish", b: "What you take off the shelf lands on a draft order for that supplier. Check it, add their order number, send. Nothing you issue goes quietly unreplaced.", pad: 36 }, + { n: "3", t: "Receive", b: "Tick lines off against the invoice as you unpack, then send each one to the shelf or to whoever is waiting. A short delivery becomes a back order on its own.", pad: 72 }, +]; + +const row: React.CSSProperties = { display: "flex", justifyContent: "space-between", padding: "8px 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13 }; + +export default async function Home() { + // A Community instance is the product, not the website: its front door is the sign-in. + if (COMMUNITY) redirect("/auth"); + const live = await plansLive(); + return ( + <> + {/* The product, with its price as it stands today, on the page that is actually about it. */} + + + + {/* Hero: full-bleed greyscale photograph with a solid ink panel flush left over it. */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ {/* The panel sizes to its widest line rather than a fixed 760: the headline scales to 84px, + where "ACCOUNTED FOR." is 753px and was breaking out of the box onto the photograph + from 1440px up. Hugging the content means the ink always contains the words. */} +
+
+ {live ? "For hospitals, aged care and clinics" : "Free for hospitals, aged care and clinics"} +
+

Every garment out the door, accounted for.

+

ThreadCount follows a garment from the shelf, to the person wearing it, to the order that puts another one back.

+
+ Open the working demo + How it works +
+
+
+
+ + {/* Card row pulls up over the hero's bottom padding. */} +
+
+
+ {CARDS.map((c, i) => ( +
+
{c.k}
+
{c.t}
+

{c.b}

+
+ ))} +
+
+
+ + {/* Proposition split */} + +
+

One coordinator can run the whole room from a phone.

+
+

Scan the garment, pick the staff member, record it. The order to replace it starts itself, the cost lands on the right ward, wing or clinic, and the month-end pack is a button rather than a weekend.

+ See everything it does +
+
+
+ + {/* The loop, with the designed vertical stagger */} + +
The loop
+
+ {LOOP.map((l) => ( +
+
{l.n}
+
{l.t}
+

{l.b}

+
+ ))} +
+
+ + {/* Pricing band */} + +
+
+
Pricing
+ {live + ?
Free to run.
Paid to host.
+ :
Free
} +
+
+

+ {live + ? `Every feature in every edition. Run it yourself for nothing, or have it hosted from $${PRICES.hostedAnnual.toLocaleString("en-AU")} a year — free for a room under ${PRICES.freeStaff} staff records.` + : "No licence, no per-device charge, no per-user charge. Every feature, every report, everyone in the room."} +

+ {live ? "The plans" : "What free covers"} +
+
+
+ + {/* Recreated product UI */} + +
On the screen
+

The whole counter on one screen.

+
+
+
+ + + + ThreadCount — Dashboard + Signed in · Coordinator +
+
+ {[["Orders", "3 open", "1 overdue — NL-48211, 6 days", true], ["Stock", "$18,240", "7 sizes at or below reorder", false], ["Staff", "212 active", "14 issues recorded this week", false]].map(([k, v, s, red], i) => ( +
+
{k}
+
{v}
+
{s}
+
+ ))} +
+
+
+
Awaiting pickup — call list
+ {[["K. Osei · Softshell Jacket M", "16 days", "tag tag-accent", "Contacted"], ["T. Nguyen · Polo Shirt L ×2", "4 days", "tag tag-neutral", "Call"], ["R. Patel · Cargo Pant 88R", "1 day", "tag tag-neutral", "Call"]].map(([n, d, cls, act]) => ( +
+ {n}{d}{act} +
+ ))} +
+
+
Reorder flags
+ {[["RN Scrub Top · S", "2 left"], ["Chef Jacket · L", "0 left"], ["Security Polo · XL", "3 left"]].map(([n, v]) => ( +
{n}{v}
+ ))} +
+
+
+
+
Inventory, issuing, ordering, stocktakes and reports all work the same way, so there is only one thing to learn.
+
+ + {/* The guides. Someone arriving from a search for their problem rather than for a product + should find the useful writing without hunting for it — and someone weighing this up + wants evidence the person behind it knows the job. These do both. */} + +
Guides
+

Written from the counter, not the brochure.

+

+ Notes on running a linen room — in a hospital, an aged-care home or a clinic — that are useful whether or not you ever use ThreadCount. + Take the method and use a clipboard if that’s what you have. +

+
+ {[ + ["How to run a uniform stocktake", "/guides/uniform-stocktake"], + ["Where the uniforms actually go", "/guides/uniform-loss"], + ["Charging uniforms to the right cost centre", "/guides/cost-centre-reporting"], + ["Entitlements and manager approvals", "/guides/manager-approvals"], + ].map(([label, href]) => ( + + {label} + + + ))} +
+
+ + + + ); +} diff --git a/app/(site)/pricing/page.tsx b/app/(site)/pricing/page.tsx new file mode 100644 index 0000000..6330535 --- /dev/null +++ b/app/(site)/pricing/page.tsx @@ -0,0 +1,195 @@ +import Link from "next/link"; +import type { Metadata } from "next"; +import { Band, CtaBand, SiteNav, body, h2, h3, kicker, small, wrap } from "@/components/site"; +import { PRICES } from "@/lib/plan"; +import { plansLive } from "@/lib/plans-live"; + +/* Two pages in one file, and which one renders is decided by the console's plans switch, not by a + * deploy — see lib/plans-live.ts for why. The first is the page as it stood while ThreadCount was + * free, kept word for word: its promises are the reason the second page reads the way it does. + * The founder's sentence under "Why …" is the same on both; only the heading's object moves. */ + +export async function generateMetadata(): Promise { + const live = await plansLive(); + return live + ? { title: "Pricing", description: `ThreadCount is free to run yourself and free to host for rooms under ${PRICES.freeStaff} staff records. Hosting a facility is $${PRICES.hostedAnnual} a year. Every feature is in every edition.`, alternates: { canonical: "/pricing" } } + : { title: "Pricing", description: "ThreadCount is free. No licence, no per-device charge, no per-user charge, and no tier above this one holding the useful parts back.", alternates: { canonical: "/pricing" } }; +} + +const INCLUDED = [ + { t: "Every person, every device", b: "Access is per person and costs nothing, so the counter, the ward or wing, and the coordinator’s phone can all be signed in at once." }, + { t: "Every feature", b: "Issuing, ordering, receiving, stocktakes, all nine report tabs and the month-end pack. There is no locked tier." }, + { t: "Every record, exportable", b: "Every report and register exports as CSV. One backup file carries the whole facility out. Nothing is held back to make leaving hard." }, +]; +const COVERED = [ + "Unlimited staff records", "Unlimited garments and sizes", "Unlimited issues and receipts", + "Scanning by USB scanner, or by phone camera where the browser reads one", "Draft replenishment orders", "Printable purchase orders and slips", + "Stocktakes with blind counting", "All nine report tabs", "Journal CSV for finance", "The one-click month-end pack", +]; +const HONEST = [ + { q: "Is there a paid tier coming?", a: "Nothing is planned. If that ever changes, the rooms using it will hear before the website does." }, + { q: "What’s the catch?", a: "It’s maintained by one person alongside a day job. That’s the honest limitation — not a cost, but a support surface worth discussing before you commit a whole site to it." }, + { q: "Do we need to buy hardware?", a: "No. It runs on the phones and computers the room already has. A USB scanner speeds up the counter but isn’t required." }, + { q: "What about hosting costs?", a: "There is nothing for you to pay. If your facility needs it hosted inside their own environment, that’s a conversation worth having early." }, +]; + +/* ---------- the page once plans are live ---------- */ +const P = PRICES; +const money = (n: number) => `$${n.toLocaleString("en-AU")}`; +const PLANS = [ + { + name: "Community", price: "$0", per: "", who: "Run it on your own server. The source is published; every feature is in it.", + rows: ["Everything, no ceiling", "Your Postgres, your backups", "Your own single sign-on broker, if you want one", "A public issue tracker"], + cta: ["Read the install guide", "/getting-started"], lead: false, + }, + { + name: "Hosted", price: money(P.hostedMonthly), per: "a facility, a month", who: `Or ${money(P.hostedAnnual)} a year. Free for a room under ${P.freeStaff} staff records.`, + rows: ["Everything", "Australian hosting, nightly backups kept 35 days", "Point-in-time restore", "Single sign-on set up for you", "Email support, next business day"], + cta: ["Start a 60-day trial", "/auth?mode=signup"], lead: true, + }, + { + name: "Health Service", price: money(P.healthServiceAnnual), per: "a year", who: `Up to ${P.healthServiceFacilities} facilities, then ${money(P.healthServiceExtra)} each.`, + rows: ["Everything, across every site", "One sign-in, switch between facilities", "Roll-up reports and a shared catalogue", "Named support, same business day", "Invoice, purchase order, security questionnaire"], + cta: ["Talk about a pilot", "/contact"], lead: false, + }, +]; +const HONEST_LIVE = [ + { q: "Is there a locked tier?", a: "No. Community, Hosted and Health Service run the same code. The differences are who runs it, how long the backups are kept, and how many sites it covers." }, + { q: "We signed up when it was free. What changes?", a: "Nothing. Every facility created before this page changed stays on the free hosted plan, with everything, for as long as it exists. That was promised here, and it is written into the terms." }, + { q: `Why ${P.freeStaff} staff records?`, a: "It is roughly where a room stops being one person and a cupboard and starts being a job. A clinic, a dental practice or a small home sits under it and pays nothing; a hospital ward is over it on day one." }, + { q: "What happens if we stop paying?", a: "The facility goes read-only after a fortnight’s grace. Every report, export and the full backup keep working. Nothing is deleted, and writing starts again when the invoice is paid." }, + { q: "How do we pay?", a: "By invoice, annually, against your purchase order. Prices are in Australian dollars before GST." }, + { q: "What’s the catch?", a: "It is still maintained by one person alongside a day job. Hosted buys a response time; it does not buy a team." }, +]; + +function FreePage() { + return ( + <> +
+
+
Pricing
+

Free

+

No licence. No per-device charge. No per-user charge. That is the whole page.

+
+
+ +

What free actually covers.

+

Everything. There is no tier above this one holding the useful parts back.

+
+ {INCLUDED.map((c, i) => ( +
+

{c.t}

+

{c.b}

+
+ ))} +
+
+ +
In the box
+
+ {COVERED.map((c) => ( +
+ {c} +
+ ))} +
+
+ +
+
+

Why it costs nothing.

+

It was built by a hospital uniform coordinator to solve their own room’s problem. It already exists, it already works, and charging for it was never the point.

+
+
+ {HONEST.map((h) => ( +
+
{h.q}
+

{h.a}

+
+ ))} +
+
+
+ + ); +} + +function LivePage() { + return ( + <> +
+
+
Pricing
+

Free to run. Paid to host.

+

Every feature is in every edition. You pay for someone to keep it running, backed up and supported. A room under {P.freeStaff} staff records pays nothing either way.

+
+
+ + +
+ {PLANS.map((p, i) => ( +
+

{p.name}

+
{p.price}
+ {p.per &&
{p.per}
} +

{p.who}

+
    + {p.rows.map((r) =>
  • {r}
  • )} +
+ {p.cta[0]} +
+ ))} +
+
+ + +
In every edition
+
+ {COVERED.map((c) => ( +
+ {c} +
+ ))} +
+

“Unlimited staff records” is true of Community, Hosted Facility and Health Service. The free hosted room holds {P.freeStaff}; past that it is a Hosted facility.

+
+ + +
+
+

Why the software costs nothing.

+

It was built by a hospital uniform coordinator to solve their own room’s problem. It already exists, it already works, and charging for it was never the point.

+

What costs money is running it for other people, carefully: the servers, the backups, and being the person who answers.

+
+
+ {HONEST_LIVE.map((h) => ( +
+
{h.q}
+

{h.a}

+
+ ))} +
+
+
+ + ); +} + +export default async function Pricing() { + const live = await plansLive(); + return ( + <> + + {live ? : } + +

Nothing to sign. Open it and look.

+

The demo runs on demonstration data. Nothing you do in it touches a real record.

+
+ Open the working demo + Getting started +
+
+ + + ); +} diff --git a/app/(site)/privacy/page.tsx b/app/(site)/privacy/page.tsx new file mode 100644 index 0000000..638f3be --- /dev/null +++ b/app/(site)/privacy/page.tsx @@ -0,0 +1,9 @@ +import LegalDoc from "@/components/LegalDoc"; +import { DOC_META } from "@/lib/legal"; + +const M = DOC_META["Privacy Policy"]; +export const metadata = { title: M.title, description: M.desc, alternates: { canonical: M.path } }; + +export default function Page() { + return ; +} diff --git a/app/(site)/reporting/page.tsx b/app/(site)/reporting/page.tsx new file mode 100644 index 0000000..5b2ef47 --- /dev/null +++ b/app/(site)/reporting/page.tsx @@ -0,0 +1,112 @@ +import Link from "next/link"; +import { Band, CtaBand, SPLIT_PAD, SiteNav, body, h2, h3, kicker, small, wrap } from "@/components/site"; + +export const metadata = { + title: "Reporting", + description: "Cost centre spend, a journal laid out for upload, valuation, shrinkage, exceptions and supplier spend — each prints or exports as CSV.", + alternates: { canonical: "/reporting" }, +}; + +// One entry per tab on the Reports screen, in the order they appear there. The month-end pack is +// not one of them — it is a button that gathers six of these into one printed document — so it is +// named under the list rather than counted as a tab. +const REPORTS = [ + ["01", "Cost centre spend", "What each department wore, at unit cost, for any month."], + ["02", "Journal export", "One debit per cost centre, formatted for upload."], + ["03", "Top stock", "The fifteen most-issued garments of the month, each with its share and its year-to-date count."], + ["04", "Stock valuation", "On-hand value by garment, at unit cost, dated."], + ["05", "Shrinkage", "Every stocktake this financial year, and what each one cost or found."], + ["06", "Exceptions", "Anyone over their operational limit, or over the monthly threshold you set."], + ["07", "Supplier spend", "Orders placed, what they came to, and the invoice numbers against them."], + ["08", "Approvals", "Manager approvals still outstanding: sets approved, collected and remaining."], + ["09", "Pre-loved", "Free reissues and what they saved, hand-ins, and what is in the pool."], +]; + +const TABLE: [string, string, string, string][] = [ + ["Willow Ward · RGH-3010", "14", "$412.60", "+$96"], + ["ICU · RGH-4010", "9", "$268.20", "−$31"], + ["Operational Support", "11", "$343.75", "+$12"], +]; + +export default function Reporting() { + return ( + <> + + + {/* Split header: type on the ground, a fixed accent column at the right. */} +
+
+
+
Reporting
+

When finance asks, it’s already done.

+

Every issue lands on the wearer’s cost centre at the price you actually paid, so the month-end numbers are already written by the time anyone asks for them.

+
+
+
9
+
Report tabs
+
+
Each one prints or exports as CSV, and six of them gather into the one-click month-end pack.
+
+
+
+ + +
+
+
+
+ Cost centre report — August + PrintCSV +
+
+
+ Cost centreItemsSpendΔ +
+ {TABLE.map(([cc, n, sp, d]) => ( +
+ {cc}{n}{sp} + {d} +
+ ))} +
+ TOTAL34$1,024.55 +
+
+
Journal: one debit per cost centre, GL 631020, ready for upload
+
+
+
+

The journal comes out formatted.

+

One debit line per cost centre against your GL account, in the layout your finance system expects. No workbook, no pivot table, no retyping.

+
+
+
+ + +
What you can pull
+
+ {REPORTS.map(([n, t, b]) => ( +
+
{n}
+
+
{t}
+
{b}
+
+
+ ))} +
+
+ + +

One click at month end.

+

The month-end pack prints one dated document: cost centre spend, the journal, top stock, shrinkage, and anything still outstanding — over a summary carrying the month’s supplier spend and the value of the stock on the shelf.

+
+ See the reports in the demo +
+
Nothing is re-priced after the fact, so a report you ran in August still says in December what it said in August.
+
+ + + + ); +} diff --git a/app/(site)/roadmap/page.tsx b/app/(site)/roadmap/page.tsx new file mode 100644 index 0000000..5c99e2f --- /dev/null +++ b/app/(site)/roadmap/page.tsx @@ -0,0 +1,85 @@ +import Link from "next/link"; +import { Band, CtaBand, PageHead, SiteNav, h2, h3, kicker, small } from "@/components/site"; + +export const metadata = { + title: "Roadmap", + description: "What’s being built next for ThreadCount, and what has shipped since the list was written. No dates — the order is intent, and it came from linen rooms asking.", + alternates: { canonical: "/roadmap" }, +}; + +// Still to come. No dates, because a date on a public page is a promise the room can't always keep. +const PLANNED = [ + { t: "Automatic reorder triggers at par level", b: "Set a par level per garment and size, and let the draft order raise itself when the shelf drops below it." }, + { t: "Offline mode for the linen room", b: "Keep issuing and counting when the wireless drops out, and sync when it returns." }, + { t: "Staff self-service issue kiosk", b: "A screen at the counter where staff identify themselves and collect what has been approved, without queueing for the coordinator." }, + { t: "Cost per staff member reporting", b: "Spend by individual as well as by ward, for the conversations about outliers." }, + { t: "Laundry contractor reconciliation", b: "Match what went out against what came back, and put a number on what the contract is losing." }, + { t: "Multi-site and group-wide rollout", b: "Several stores running independently — a hospital’s linen rooms, an aged-care group’s homes, a network of clinics — with combined reporting above them." }, +]; + +/* Four things that were on this list have since been built, and leaving them tagged "Planned" + * made the page contradict /features and /faq — a reader could not tell which one to believe. They + * stay on the page rather than quietly disappearing from it, because a room that read the list + * three months ago should be able to see what happened to it, but they are out of the numbering + * of what is still to come. Size history was listed here as "size and fit history"; what shipped is + * the size half — the record knows the last size somebody was issued, not whether it fitted them — + * so the title says size only rather than promising a fit note nobody writes. The two apps are the + * one honest half-state: both are built, and the Play listings are in review, so the tag says + * exactly that until the listings are public. */ +const DONE = [ + { t: "Barcode and QR scanning on phone", b: "Scanning for issuing, counting and receiving, using the supplier codes already on the garment. Chrome and Edge read a barcode from the camera; on an iPhone or iPad, and in Firefox, the code is typed or read with a USB scanner.", tag: "Shipped" }, + { t: "Size history per person", b: "Every staff member carries the size of the last one they were issued, garment by garment, so the counter opens on what they had rather than on a guess — on the coordinator’s desk and in the staff app both.", tag: "Shipped" }, + { t: "Bulk import from existing spreadsheets", b: "The staff register, catalogue, locations and opening stock come in from CSV, each with a template to fill.", tag: "Shipped" }, + { t: "Android app", b: "Two of them: the counter app for the linen room, and a staff app for the people who wear the uniform. Both are built and the Play listings are in review.", tag: "In review" }, +]; + +function Row({ n, t, b, tag, accent }: { n: string; t: string; b: string; tag: string; accent?: boolean }) { + return ( +
+
{n}
+
+

{t}

+

{b}

+
+
{tag}
+
+ ); +} + +export default function Roadmap() { + return ( + <> + + + + + {PLANNED.map((it, i) => )} +
+ + + +
Since this list was written
+
+ {DONE.map((it) => )} +
+
+ + +
+
+

Something you need that isn’t on this list?

+
The list came from real linen rooms asking. Yours can change the order of it.
+
+ Tell me what’s missing +
+
+ + + + ); +} diff --git a/app/(site)/security/page.tsx b/app/(site)/security/page.tsx new file mode 100644 index 0000000..1e287cd --- /dev/null +++ b/app/(site)/security/page.tsx @@ -0,0 +1,75 @@ +import Link from "next/link"; +import { Band, CtaBand, PageHead, SiteNav, body, h2, h3, kicker } from "@/components/site"; + +export const metadata = { + title: "Security & data", + description: "ThreadCount records who was issued what, which makes it a staff record. Where it lives, who can see it, and what to agree with information security first.", + alternates: { canonical: "/security" }, +}; + +const PILLARS = [ + { t: "Your facility’s records", b: "Every issue, count and order belongs to the facility that entered it. Nothing is pooled with another site." }, + { t: "Two roles, clear limits", b: "Admin manages settings, catalogue and pricing. Issuer issues, counts and receives. Prices and history cannot be edited by accident." }, + { t: "History is kept", b: "Issues, receipts and counts are written with the date and the person who recorded them, and stay that way." }, + { t: "Export whenever", b: "Every report and register exports as CSV, the ward request queue included. One backup file takes the whole facility out at once, so the data is never held hostage in the product." }, +]; + +const PLAIN = [ + "Only the people you invite can see your room’s records.", + "Staff names, the ward or clinic they work on, and cost centres are stored to run the loop, and for nothing else.", + "No garment data is sold, shared, or used to train anything.", + "Slips print for a signature on paper. A ward-round handover can be signed on screen instead, and that signature is stored with the delivery, shown only to your facility, and deleted with the record.", + "Deactivating a staff member removes them from issuing while keeping the historical record intact for finance.", + "You can export the whole facility to one file and walk away at any point.", +]; + +export default function Security() { + return ( + <> + + It holds staff names. So it holds them carefully.} + lede="ThreadCount records who was issued what. That makes it a staff record, and it is built to be treated as one." + /> + + +
+ {PILLARS.map((p, i) => ( +
+

{p.t}

+

{p.b}

+
+ ))} +
+
+ + +
In plain English
+

The commitments, without the certification language nobody reads.

+
+ {PLAIN.map((p) => ( +
+ {p} +
+ ))} +
+
+ + +
+
Before you deploy
+

Take it to your information security team early.

+

Hosting location, backup arrangement and retention period should be agreed with your facility before real staff data goes in — with an information security team in a hospital, or with whoever signs off on systems in a smaller practice. Ask, and you’ll get the answers in writing.

+
+ Ask a security question + Read the data security policy +
+
+
+ + + + ); +} diff --git a/app/(site)/support/page.tsx b/app/(site)/support/page.tsx new file mode 100644 index 0000000..b2aca1d --- /dev/null +++ b/app/(site)/support/page.tsx @@ -0,0 +1,77 @@ +import Link from "next/link"; +import { Band, CtaBand, PageHead, SiteNav, body, h2, h3, kicker } from "@/components/site"; +import { plansLive } from "@/lib/plans-live"; + +export const metadata = { + title: "Support", + description: "Support comes from the person who wrote the software — what that means in practice, and what it honestly doesn’t cover.", + alternates: { canonical: "/support" }, +}; + +// The handoff stated these as commitments ("Same day", "2 working days", "A week"). They are phrased +// here as what is aimed for, because one person alongside a day job can't guarantee a clock. +const TIERS = [ + { k: "Can’t issue", time: "Usually same day", t: "The counter is stopped", b: "Anything stopping the room issuing, receiving or counting gets looked at first — normally the day it’s reported." }, + { k: "Something’s wrong", time: "Usually a couple of days", t: "It works, but not properly", b: "A number that looks off, a report that won’t export, a slip printing the wrong field." }, + { k: "Can it do", time: "Usually within the week", t: "Questions and requests", b: "How-to questions and feature requests. Requests go on the roadmap in the order rooms ask for them." }, +]; + +const HONEST = [ + "Support is one person, alongside a day job. Nights and weekends aren’t covered.", + "There is no phone line. Everything goes through the contact form so it’s written down.", + "None of the times above is a contractual service level — they’re what usually happens, not a promise.", + "If your facility needs guaranteed response times in writing, have that conversation before a whole site depends on it.", +]; + +export default async function Support() { + const live = await plansLive(); + return ( + <> + + + + +
+ {TIERS.map((t, i) => ( +
+
{t.k}
+
{t.time}
+
+

{t.t}

+

{t.b}

+
+ ))} +
+ + + +
Being straight about it
+

{live ? "ThreadCount is maintained by one person." : "ThreadCount is free and maintained by one person."}

+

Here’s what that honestly means, so nobody finds out the hard way.

+
+ {HONEST.map((h) => ( +
+ {h} +
+ ))} +
+
+ + +
+

Stuck on something? Say what it is.

+
+ Get in touch + Check the FAQ +
+
+
+ + + + ); +} diff --git a/app/(site)/terms/page.tsx b/app/(site)/terms/page.tsx new file mode 100644 index 0000000..e42a1d5 --- /dev/null +++ b/app/(site)/terms/page.tsx @@ -0,0 +1,9 @@ +import LegalDoc from "@/components/LegalDoc"; +import { DOC_META } from "@/lib/legal"; + +const M = DOC_META["Terms of Service"]; +export const metadata = { title: M.title, description: M.desc, alternates: { canonical: M.path } }; + +export default function Page() { + return ; +} diff --git a/app/.well-known/assetlinks.json/route.ts b/app/.well-known/assetlinks.json/route.ts new file mode 100644 index 0000000..c742ea9 --- /dev/null +++ b/app/.well-known/assetlinks.json/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +/* Digital Asset Links — what lets the staff app open threadcount.tech/my links itself. + * + * Without this file, tapping "Approve" in a manager's email opens Chrome rather than the app they + * installed. Android fetches it over HTTPS and checks that the certificate it names matches the + * one the installed app was signed with. + * + * The fingerprint has to be **Play's app-signing certificate**, not the upload key — with Play App + * Signing, Google re-signs the bundle, so the certificate on the device is theirs. Find it at + * Play Console → Test and release → Setup → App signing → "SHA-256 certificate fingerprint", and + * put it in the environment as ANDROID_APP_FINGERPRINTS. Several may be listed, comma-separated: + * both apps, or an old and a new key during a rotation. + * + * Served from an env var rather than a static file on purpose. The fingerprint is only knowable + * after the first upload, and a deploy is a cheaper way to add it than a code change — and if it + * is ever rotated, nothing here needs editing. + * + * With no fingerprint configured this returns an empty statement list, which is the honest answer: + * no app is authorised to handle these links yet, and Android falls back to the browser exactly as + * it does today. It never returns a malformed or guessed fingerprint. + */ +const PACKAGES: { name: string; label: string }[] = [ + { name: "tech.threadcount.staff", label: "ANDROID_APP_FINGERPRINTS_STAFF" }, + { name: "tech.threadcount.app", label: "ANDROID_APP_FINGERPRINTS_COUNTER" }, +]; + +function fingerprints(specific: string): string[] { + const raw = process.env[specific] || process.env.ANDROID_APP_FINGERPRINTS || ""; + return raw + .split(",") + .map((f) => f.trim().toUpperCase()) + // A SHA-256 fingerprint is 32 colon-separated hex pairs. Anything else is a paste error, and + // shipping it would just make Android's verification fail silently. + .filter((f) => /^([0-9A-F]{2}:){31}[0-9A-F]{2}$/.test(f)); +} + +export async function GET() { + const statements = PACKAGES.flatMap((p) => { + const fps = fingerprints(p.label); + if (!fps.length) return []; + return [{ + relation: [ + // Opens threadcount.tech/my links in the app instead of the browser. + "delegate_permission/common.handle_all_urls", + // Credential sharing: the site and the app are one account system, so a password saved on + // either autofills on the other. A staff member sets theirs once, on whichever surface the + // printed slip's link happened to open on, and shouldn't have to remember which. + "delegate_permission/common.get_login_creds", + ], + target: { namespace: "android_app", package_name: p.name, sha256_cert_fingerprints: fps }, + }]; + }); + + return NextResponse.json(statements, { + headers: { + "content-type": "application/json", + // Android caches this; an hour is short enough that adding a fingerprint takes effect the + // same day, and long enough that it isn't fetched on every link tap. + "cache-control": "public, max-age=3600", + }, + }); +} diff --git a/app/api/2fa/route.ts b/app/api/2fa/route.ts new file mode 100644 index 0000000..088df20 --- /dev/null +++ b/app/api/2fa/route.ts @@ -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 }); +} diff --git a/app/api/activity/route.ts b/app/api/activity/route.ts new file mode 100644 index 0000000..dfb4ead --- /dev/null +++ b/app/api/activity/route.ts @@ -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. + */ + +/** `|` — 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, + }); +} diff --git a/app/api/auth/2fa/route.ts b/app/api/auth/2fa/route.ts new file mode 100644 index 0000000..c812b13 --- /dev/null +++ b/app/api/auth/2fa/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { pwVersion, setSessionCookie } from "@/lib/session"; +import { sameOriginJson } from "@/lib/csrf"; +import { allow, clientIp } from "@/lib/ratelimit"; +import { decryptSecret, hashRecoveryCode, totpVerify } from "@/lib/totp"; +import { 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 }; + 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); + // 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 }); +} diff --git a/app/api/auth/demo/reset/route.ts b/app/api/auth/demo/reset/route.ts new file mode 100644 index 0000000..6270fef --- /dev/null +++ b/app/api/auth/demo/reset/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from "next/server"; +import { timingSafeEqual } from "crypto"; +import { resetDemo } from "@/lib/demo"; + +export const dynamic = "force-dynamic"; + +// Called by the host's threadcount-demo-reset.timer every 20 minutes with the shared token. +export async function POST(req: NextRequest) { + const want = process.env.DEMO_RESET_TOKEN || ""; + const got = req.headers.get("x-demo-token") || ""; + if (!want || want.length !== got.length || !timingSafeEqual(Buffer.from(want), Buffer.from(got))) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + const f = await resetDemo(); + return NextResponse.json({ ok: true, facility: f.name, resetAt: f.demoResetAt }); +} diff --git a/app/api/auth/demo/route.ts b/app/api/auth/demo/route.ts new file mode 100644 index 0000000..cdd6d1a --- /dev/null +++ b/app/api/auth/demo/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { COOKIE_NAME, currentUser, signSession } from "@/lib/session"; +import { allow, clientIp } from "@/lib/ratelimit"; +import { demoUserFor, ensureDemo } from "@/lib/demo"; +import { switches } from "@/lib/switches"; + +export const dynamic = "force-dynamic"; + +// One-click entry into the shared demo facility. Redirects use a raw relative Location so the +// proxy in front of the app can't rewrite the host. +export async function GET(req: NextRequest) { + if (!(await switches()).demoOpen) return NextResponse.json({ error: "The demo is switched off." }, { status: 404 }); + if (req.headers.get("sec-fetch-site") === "cross-site") return new NextResponse(null, { status: 303, headers: { Location: "/demo" } }); + const as = req.nextUrl.searchParams.get("as") === "issuer" ? "issuer" : "admin"; + // A link can't be used to swap a signed-in coordinator's real session for the demo (login CSRF). + const cur = await currentUser(); + if (cur && !cur.isDemo) return new NextResponse(null, { status: 303, headers: { Location: "/demo?signedin=1" } }); + if (!allow("demo:" + clientIp(req.headers), 30, 10 * 60 * 1000)) return NextResponse.json({ error: "Too many requests — try again shortly." }, { status: 429 }); + const f = await ensureDemo(); + const u = demoUserFor(f, as); + const res = new NextResponse(null, { status: 303, headers: { Location: "/app" } }); + res.cookies.set(COOKIE_NAME, signSession(u.id, u.passwordHash, 60 * 60 * 4), { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 60 * 60 * 4 }); + return res; +} diff --git a/app/api/auth/forgot/route.ts b/app/api/auth/forgot/route.ts new file mode 100644 index 0000000..da37138 --- /dev/null +++ b/app/api/auth/forgot/route.ts @@ -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 } = resetEmail(user.first, resetUrl(token)); + const sent = await sendTo(email, subject, text); + // 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() }); +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..f77cf8e --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/lib/db"; +import { pwVersion, setSessionCookie } from "@/lib/session"; +import { mintTicket } 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(); + +/** 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 }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + const email = String(body.email || "").trim().toLowerCase().slice(0, 160); + const password = String(body.password || "").slice(0, 200); + // Spray protection independent of the per-(ip,email) counter below. Both buckets count only the + // attempts that FAILED — a whole hospital signs in from one NAT address at shift change, and a + // ceiling on attempts would have to lock that ward out to be worth anything against an attacker. + const ipKey = clientIp(req.headers); + if (over("login-ip:" + ipKey, 40, 15 * 60 * 1000) || (email && over("login-email:" + email, 25, 15 * 60 * 1000))) return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 }); + if (!email || !password) return NextResponse.json({ error: "Enter your email and password." }, { status: 400 }); + + // nginx appends the real client IP last; earlier entries are client-supplied and spoofable. + const xff = req.headers.get("x-forwarded-for")?.split(",").map((x) => x.trim()).filter(Boolean) || []; + const ip = xff[xff.length - 1] || "local"; + if (attempts.size > 5000) for (const [kk, v] of attempts) if (Date.now() - v.t > 15 * 60 * 1000) attempts.delete(kk); + const k = `${ip}|${email}`; + const a = attempts.get(k); + if (a && a.n >= 8 && Date.now() - a.t < 15 * 60 * 1000) return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 }); + + const cfErr = await verifyTurnstile(body.cfToken, ipKey); if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 }); + const u = await prisma.user.findUnique({ where: { email } }); + /* One box, both kinds of account. + * + * A wearer reaches the product the way anyone else does — the home page, then Log in — and types + * the details they set up in the staff app. So when this address has no coordinator account, the + * register is asked before the answer is called wrong. + * + * A coordinator account always wins: it is the one with the counter, the orders and the register + * behind it, and a coordinator who also wears a uniform can open their own record from inside the + * app. One address therefore has one destination, every time. + * + * This is a lookup, not a second attempt. "Try the coordinator, and if that fails try the staff + * one" would score a failure against every single staff sign-in, and these ceilings count + * failures — behind one hospital's NAT address at shift change that is a locked-out ward. + */ + if (!u) { + const s = await signInStaff(email, password, ipKey, false); + if (s.kind === "ok") return NextResponse.json({ ok: true, name: s.name, staff: true }); + if (s.kind === "error") return NextResponse.json({ error: s.error }, { status: s.status }); + // `none`: no staff account either, so this falls through to the answer below, which counts the + // failure once and says the same thing it has always said. + } + const ok = u ? await bcrypt.compare(password, u.passwordHash) : await bcrypt.compare(password, "$2b$12$C6UzMDM.H6dfI/f/IKcEeO5x3FvDS3kqB6r0Jt3g7Lz0vX4o0JZ1u"); + if (!u || !ok) { + attempts.set(k, { n: (a && Date.now() - a.t < 15 * 60 * 1000 ? a.n : 0) + 1, t: Date.now() }); + fail("login-ip:" + ipKey, 15 * 60 * 1000); + if (email) fail("login-email:" + email, 15 * 60 * 1000); + // An address with no account here is recorded nowhere: there is no facility to file it under, + // and a log of attempts on addresses that don't exist would be a list of other people's email + // addresses that nobody asked us to keep. + if (u) recordAuthEvent(actorFor(u), "auth:signin.failed", ipKey); + return NextResponse.json({ error: "Email or password doesn’t match." }, { status: 401 }); + } + attempts.delete(k); + if (u.inactive) { + // The right password on an account that has been taken away is worth knowing about. + recordAuthEvent(actorFor(u), "auth:signin.refused", ipKey, "inactive"); + return NextResponse.json({ error: "This account has been deactivated. Ask an admin at your facility to reactivate it." }, { status: 403 }); + } + const fac = await prisma.facility.findUnique({ where: { id: u.facilityId }, select: { isDemo: true, ssoEnabled: true, ssoRequired: true } }); + if (fac?.isDemo) return NextResponse.json({ error: "Demo accounts can’t log in here — open the demo from the home page." }, { status: 403 }); + // The facility has decided its people sign in through its own identity provider. The password + // was right, and it is still refused — except for the admin the facility keeps as its fire + // escape. The box sends them on to single sign-on rather than reporting a failure. + if (fac?.ssoEnabled && fac.ssoRequired && !u.ssoBreakGlass) { + recordAuthEvent(actorFor(u), "auth:signin.refused", ipKey, "sso required"); + return NextResponse.json({ error: "Your facility signs in with single sign-on.", ssoRequired: true }, { status: 403 }); + } + // With a second factor on the account the password alone opens nothing. The ticket says only + // "this password was correct", is accepted by no other endpoint, and expires in five minutes. + if (u.totpEnabledAt) { + return NextResponse.json({ need2fa: true, ticket: mintTicket(u.id, pwVersion(u.passwordHash)) }); + } + + await setSessionCookie(u.id, u.passwordHash); + recordAuthEvent(actorFor(u), "auth:signin", ipKey, "password"); + return NextResponse.json({ ok: true, name: `${u.first} ${u.last}`, role: u.role }); +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..4ced033 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -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 }); +} diff --git a/app/api/auth/reset/route.ts b/app/api/auth/reset/route.ts new file mode 100644 index 0000000..dc6cd24 --- /dev/null +++ b/app/api/auth/reset/route.ts @@ -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 }); +} diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts new file mode 100644 index 0000000..c1678a9 --- /dev/null +++ b/app/api/auth/signup/route.ts @@ -0,0 +1,93 @@ +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"; + +export const dynamic = "force-dynamic"; + +/* 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. + */ +function welcomeEmail(first: string, facility: string) { + const subject = "Your ThreadCount facility is set up"; + const text = [ + `Hi ${first || "there"},`, + "", + `${facility} is set up on ThreadCount, and this address is the coordinator account for it.`, + "", + "Keep this message. This is the address a password reset is sent to, and it is the only way", + "back into the facility if the password is forgotten — so if it is wrong, sign in and add a", + "second admin with an address that works, under Settings → Users.", + "", + `${process.env.NEXT_PUBLIC_SITE_URL || "https://threadcount.tech"}/app`, + "", + "— ThreadCount", + ].join("\n"); + return { subject, text }; +} + +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; + 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" }; + const f = await tx.facility.create({ data: { name: facility, coordinator: `${first} ${last}`, ...planData } }); + 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); + 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() }); +} diff --git a/app/api/auth/sso/callback/route.ts b/app/api/auth/sso/callback/route.ts new file mode 100644 index 0000000..a8b2e4c --- /dev/null +++ b/app/api/auth/sso/callback/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { setSessionCookie } from "@/lib/session"; +import { setStaffCookie } from "@/lib/staffsession"; +import { allow, clientIp, fail, over } from "@/lib/ratelimit"; +import { recordAuthEvent } from "@/lib/audit"; +import { exchangeCode, fetchProfile, readState, ssoConfigured, SsoError, STATE_COOKIE } from "@/lib/sso"; + +export const dynamic = "force-dynamic"; + +/* The broker sends the browser back here with ?code&state once the identity provider has spoken. + * This route is the whole trust boundary, so, in order: + * + * 1. state must be the nonce in our signed cookie, which also says WHICH facility this login + * was started for and whether a coordinator or a wearer is expected; + * 2. the code is exchanged server-side and the profile read from the broker — the IdP's tokens + * never touch the browser; + * 3. the profile's email must match an existing, active account IN THAT FACILITY: a coordinator + * (User) or, if the facility allows wearers, a staff account. Nothing is ever created here — + * an address the identity provider vouches for but the facility never added is not a person + * the facility asked to let in; + * 4. only then is the ordinary session cookie minted, marked sso. A passed assertion is a whole + * authentication (the identity provider owns the second factor), so no TOTP step follows. + * + * No Turnstile and no same-origin check: this is a top-level navigation from the broker's origin, + * and the state cookie is the CSRF proof. Every failure lands on /auth?error=…, never a bypass. */ +const back = (path: string) => { + const res = new NextResponse(null, { status: 303, headers: { Location: path } }); + res.cookies.set(STATE_COOKIE, "", { httpOnly: true, sameSite: "lax", path: "/api/auth/sso", maxAge: 0 }); + return res; +}; + +export async function GET(req: NextRequest) { + if (!ssoConfigured()) return back("/auth?error=sso_unavailable"); + const ip = clientIp(req.headers); + // Failures only, so a whole site signing in behind one address is never locked out. + if (over("sso-callback:" + ip, 20, 15 * 60 * 1000)) return back("/auth?error=sso_failed"); + const bad = (path: string) => { fail("sso-callback:" + ip, 15 * 60 * 1000); return back(path); }; + if (!allow("sso-callback-all:" + ip, 120, 15 * 60 * 1000)) return back("/auth?error=sso_failed"); + + const code = req.nextUrl.searchParams.get("code") || ""; + const state = req.nextUrl.searchParams.get("state") || ""; + if (req.nextUrl.searchParams.get("error") || !code || !state) return bad("/auth?error=sso_failed"); + + const st = readState(req.cookies.get(STATE_COOKIE)?.value, state); + if (!st) return bad("/auth?error=sso_state"); + const f = await prisma.facility.findUnique({ where: { id: st.facilityId }, select: { id: true, isDemo: true, ssoEnabled: true, ssoStaff: true } }); + if (!f || f.isDemo || !f.ssoEnabled) return bad("/auth?error=sso_unavailable"); + + let email: string; + try { + email = (await fetchProfile(await exchangeCode(code))).email; + } catch (e) { + if (!(e instanceof SsoError)) console.error("[sso] callback exchange failed", e); + return bad("/auth?error=sso_failed"); + } + + if (st.aud === "staff") { + if (!f.ssoStaff) return bad("/auth?error=sso_unavailable"); + const acc = await prisma.staffAccount.findUnique({ where: { email }, select: { id: true, facilityId: true, passwordHash: true, staff: { select: { id: true, first: true, last: true, inactive: true } } } }); + if (!acc || acc.facilityId !== f.id) return bad("/auth?error=sso_no_account"); + if (acc.staff.inactive) return bad("/auth?error=sso_inactive"); + await prisma.staffAccount.update({ where: { id: acc.id }, data: { lastSeenAt: new Date() } }); + await setStaffCookie(acc.id, acc.passwordHash, true); + recordAuthEvent({ facilityId: acc.facilityId, userId: acc.staff.id, userName: `${acc.staff.first} ${acc.staff.last}` }, "staff:signin", ip, "sso"); + return back("/my"); + } + + const u = await prisma.user.findUnique({ where: { email }, select: { id: true, facilityId: true, passwordHash: true, first: true, last: true, inactive: true } }); + if (!u || u.facilityId !== f.id) { + // A wearer typing at the shared box reaches here with aud "user"; if the facility lets its + // wearers use SSO, look them up too rather than sending them away. + if (f.ssoStaff) { + const acc = await prisma.staffAccount.findUnique({ where: { email }, select: { id: true, facilityId: true, passwordHash: true, staff: { select: { id: true, first: true, last: true, inactive: true } } } }); + if (acc && acc.facilityId === f.id) { + if (acc.staff.inactive) return bad("/auth?error=sso_inactive"); + await prisma.staffAccount.update({ where: { id: acc.id }, data: { lastSeenAt: new Date() } }); + await setStaffCookie(acc.id, acc.passwordHash, true); + recordAuthEvent({ facilityId: acc.facilityId, userId: acc.staff.id, userName: `${acc.staff.first} ${acc.staff.last}` }, "staff:signin", ip, "sso"); + return back("/my"); + } + } + return bad("/auth?error=sso_no_account"); + } + if (u.inactive) return bad("/auth?error=sso_inactive"); + await setSessionCookie(u.id, u.passwordHash, true); + recordAuthEvent({ facilityId: u.facilityId, userId: u.id, userName: `${u.first} ${u.last}` }, "auth:signin", ip, "sso"); + return back("/app"); +} diff --git a/app/api/auth/sso/lookup/route.ts b/app/api/auth/sso/lookup/route.ts new file mode 100644 index 0000000..80b0e55 --- /dev/null +++ b/app/api/auth/sso/lookup/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sameOriginJson } from "@/lib/csrf"; +import { allow, clientIp } from "@/lib/ratelimit"; +import { facilityForEmail, ssoConfigured } from "@/lib/sso"; + +export const dynamic = "force-dynamic"; + +/* Does this address belong to a facility that signs in with single sign-on? + * + * Asked by the Log in box once an address is typed, so the box can offer "Continue with single + * sign-on" before anyone reaches for a password. It answers about a DOMAIN, never a person: a + * facility that registered its domain is a fact about the facility, and the reply carries nothing + * about whether the address itself has an account. Throttled per address, since it is a lookup + * anyone may make. */ +export async function POST(req: NextRequest) { + const csrf = sameOriginJson(req); + if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + if (!allow("sso-lookup:" + clientIp(req.headers), 60, 15 * 60 * 1000)) return NextResponse.json({ sso: false }); + if (!ssoConfigured()) return NextResponse.json({ sso: 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); + const f = await facilityForEmail(email); + if (!f) return NextResponse.json({ sso: false }); + return NextResponse.json({ sso: true, required: f.ssoRequired, facility: f.name }); +} diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts new file mode 100644 index 0000000..a24493c --- /dev/null +++ b/app/api/auth/sso/start/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { currentUser } from "@/lib/session"; +import { allow, clientIp } from "@/lib/ratelimit"; +import { buildAuthorizeUrl, facilityForEmail, mintState, ssoConfigured, STATE_COOKIE } from "@/lib/sso"; + +export const dynamic = "force-dynamic"; + +/* Begin single sign-on for the facility that owns this address's domain. + * + * Mints a nonce, keeps it in a signed, httpOnly cookie bound to that facility (and to whether a + * wearer or a coordinator is expected back), echoes it as the OAuth `state`, and sends the browser + * to the broker. The callback requires the returned state to be the cookie's nonce, so a forged + * or replayed callback has nothing to match. The redirect target handed to the broker is the + * product's one fixed callback address — never a request header. + * + * A cross-site link may not start this (login CSRF: a stranger's page must not be able to sign + * you into an account of its choosing), and a signed-in coordinator is sent to the app instead. */ +export async function GET(req: NextRequest) { + if (!ssoConfigured()) return NextResponse.json({ error: "Single sign-on is not available." }, { status: 404 }); + if (req.headers.get("sec-fetch-site") === "cross-site") return new NextResponse(null, { status: 303, headers: { Location: "/auth" } }); + if (!allow("sso-start:" + clientIp(req.headers), 60, 15 * 60 * 1000)) return new NextResponse(null, { status: 303, headers: { Location: "/auth?error=sso_failed" } }); + const cur = await currentUser(); + if (cur && !cur.isDemo) return new NextResponse(null, { status: 303, headers: { Location: "/app" } }); + + const email = (req.nextUrl.searchParams.get("email") || "").trim().toLowerCase().slice(0, 160); + const f = await facilityForEmail(email); + if (!f) return new NextResponse(null, { status: 303, headers: { Location: "/auth?error=sso_unavailable" } }); + + const aud = req.nextUrl.searchParams.get("as") === "staff" ? "staff" : "user"; + if (aud === "staff" && !f.ssoStaff) return new NextResponse(null, { status: 303, headers: { Location: "/auth?error=sso_unavailable" } }); + const { nonce, cookie } = mintState(f.id, aud); + const res = new NextResponse(null, { status: 302, headers: { Location: buildAuthorizeUrl(f.id, nonce) } }); + res.cookies.set(STATE_COOKIE, cookie, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", // the broker returns by a top-level navigation, which lax still sends + path: "/api/auth/sso", + maxAge: 10 * 60, + }); + return res; +} diff --git a/app/api/backup/route.ts b/app/api/backup/route.ts new file mode 100644 index 0000000..ba7409b --- /dev/null +++ b/app/api/backup/route.ts @@ -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"`, + }, + }); +} diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts new file mode 100644 index 0000000..e9da801 --- /dev/null +++ b/app/api/contact/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { allow, clientIp } from "@/lib/ratelimit"; +import { sameOriginJson } from "@/lib/csrf"; +import { verifyTurnstile } from "@/lib/turnstile"; +import { mailConfigured, sendMail } from "@/lib/mail"; + +export const dynamic = "force-dynamic"; + +const str = (v: unknown, max: number) => String(v ?? "").trim().slice(0, max); + +/* Retention. + * + * A message through the contact form carries a name, a work address, a facility, a role, whatever + * the person chose to write and the address they wrote it from. It was kept forever: the model has + * no facility to cascade from, so nothing would ever have deleted one. Twelve months is long + * enough for the enquiry and any follow-up it turns into, and the privacy note says the same + * number — this is the mechanism that makes that sentence true rather than aspirational. + * + * Swept from here rather than from a cron, because a cron is a second thing to deploy and this + * table only grows when this handler runs. The limiter is doing duty as an interval: one sweep an + * hour, and the message the person is sending never waits on it. */ +const RETENTION_DAYS = 365; + +function pruneOldMessages() { + if (!allow("contact-prune", 1, 60 * 60 * 1000)) return; + const cutoff = new Date(Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000); + void prisma.contactMessage + .deleteMany({ where: { createdAt: { lt: cutoff } } }) + .then((r) => { if (r.count) console.log(`[contact] retention: removed ${r.count} message(s) older than ${RETENTION_DAYS} days`); }) + .catch((e) => console.error("[contact] retention sweep failed:", (e as Error).message)); +} + +export async function POST(req: NextRequest) { + const csrf = sameOriginJson(req); + if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + + const ip = clientIp(req.headers); + // Two buckets: a burst guard and a slower daily ceiling, so one address can't grind through it. + if (!allow("contact:" + ip, 5, 60 * 60 * 1000) || !allow("contact-day:" + ip, 20, 24 * 60 * 60 * 1000)) { + return NextResponse.json({ error: "That's a few messages in a short time. Try again later, or email hello@threadcount.tech." }, { status: 429 }); + } + + let b: Record; + try { b = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + + // Honeypot: a real person never fills this in. + if (str(b.company, 100)) return NextResponse.json({ ok: true }); + + const name = str(b.name, 120); + const email = str(b.email, 160).toLowerCase(); + const message = str(b.message, 4000); + if (!name) return NextResponse.json({ error: "Add your name so I know who I'm replying to." }, { status: 400 }); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ error: "Add an email address I can reply to." }, { status: 400 }); + if (message.length < 10) return NextResponse.json({ error: "Say a little more about what you need." }, { status: 400 }); + + const cfErr = await verifyTurnstile(b.cfToken, ip); + if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 }); + + pruneOldMessages(); + + const row = await prisma.contactMessage.create({ + data: { + name, email, message, ip, + role: str(b.role, 120), facility: str(b.facility, 160), + topic: str(b.topic, 60), slot: str(b.slot, 60), + }, + }); + + const emailed = await sendMail( + `ThreadCount contact — ${row.topic || "A question"} — ${name}`, + [`From: ${name}${row.role ? ` (${row.role})` : ""}`, row.facility && `Facility: ${row.facility}`, `Email: ${email}`, + row.topic && `Topic: ${row.topic}`, row.slot && `Walkthrough: ${row.slot}`, "", message, "", `Received ${row.createdAt.toISOString()} from ${ip}`] + .filter(Boolean).join("\n"), + email, + ); + if (emailed) await prisma.contactMessage.update({ where: { id: row.id }, data: { emailed: true } }); + else if (!mailConfigured()) console.warn("[contact] stored", row.id, "— SMTP not configured, no notification sent"); + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..370f22e --- /dev/null +++ b/app/api/health/route.ts @@ -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" } }); + } +} diff --git a/app/api/logo/route.ts b/app/api/logo/route.ts new file mode 100644 index 0000000..87caaf0 --- /dev/null +++ b/app/api/logo/route.ts @@ -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" } }); +} diff --git a/app/api/lookup/route.ts b/app/api/lookup/route.ts new file mode 100644 index 0000000..680fea1 --- /dev/null +++ b/app/api/lookup/route.ts @@ -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; + 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 }>(); +const CACHE_MS = 12 * 60 * 60 * 1000; + +async function getJson(url: string): Promise { + 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 }); +} diff --git a/app/api/mutate/route.ts b/app/api/mutate/route.ts new file mode 100644 index 0000000..1ae07a6 --- /dev/null +++ b/app/api/mutate/route.ts @@ -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 }); + } +} diff --git a/app/api/ops/auth/login/route.ts b/app/api/ops/auth/login/route.ts new file mode 100644 index 0000000..c5c14a5 --- /dev/null +++ b/app/api/ops/auth/login/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/lib/db"; +import { sameOriginJson } from "@/lib/csrf"; +import { clientIp, fail, over } from "@/lib/ratelimit"; +import { setOpsCookie, logOperatorEvent } from "@/lib/ops/session"; +import { verifyOperatorCode } from "@/lib/ops/totp"; +import { hashRecoveryCode } from "@/lib/totp"; + +export const dynamic = "force-dynamic"; + +/* The break-glass door for the operations console. + * + * Single sign-on is the front door. This is the fire escape, and a fire escape must not depend + * on the thing that is on fire — so there is no Turnstile here. The sitekey is bound to the + * product's domain and compiled in at build time; on this hostname it would refuse with a + * generic "security check failed", discovered during the exact incident this route exists for. + * + * Instead: failures are counted under the console's own buckets. Not `login-ip:` — that is the + * product's, and a public credential-stuffing run against coordinator accounts must not be able + * to lock the operator out of the console. The limits are tighter than the product's because + * there is one operator, not a ward arriving at shift change. + * + * ⛔ This route mints an OPERATOR session and nothing else. It must never call setSessionCookie + * or setStaffCookie; an operator signing in as a customer is the data plane by another door. */ + +// A constant to compare against when there is no such operator, so a missing address and a +// wrong password take the same time. +const DUMMY = "$2b$12$C6UzMDM.H6dfI/f/IKcEeO5x3FvDS3kqB6r0Jt3g7Lz0vX4o0JZ1u"; +const WINDOW = 15 * 60 * 1000; + +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; code?: 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 code = body.code === undefined || body.code === null ? "" : String(body.code).slice(0, 16); + + const ip = clientIp(req.headers); + if (over("ops-login-ip:" + ip, 20, WINDOW) || (email && over("ops-login-email:" + email, 10, WINDOW))) { + 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 }); + + const op = await prisma.operator.findUnique({ + where: { email }, + select: { id: true, name: true, passwordHash: true, inactive: true, totpSecret: true, totpEnabledAt: true }, + }); + const ok = await bcrypt.compare(password, op?.passwordHash ?? DUMMY); + if (!op || !ok) { + fail("ops-login-ip:" + ip, WINDOW); + if (email) fail("ops-login-email:" + email, WINDOW); + if (op) logOperatorEvent({ operatorId: op.id, action: "ops:signin.failed", detail: "password", ip }); + return NextResponse.json({ error: "Email or password doesn’t match." }, { status: 401 }); + } + if (op.inactive) { + logOperatorEvent({ operatorId: op.id, action: "ops:signin.refused", detail: "inactive", ip }); + return NextResponse.json({ error: "This operator account has been deactivated." }, { status: 403 }); + } + + // Once a second factor is enrolled, the password alone opens nothing. `needCode` tells the form + // to ask for it; a wrong code is a counted failure like a wrong password. + let second = ""; + if (op.totpEnabledAt) { + if (!code) return NextResponse.json({ error: "Enter the code from your authenticator app.", needCode: true }, { status: 401 }); + // Ten or more letters and digits is a recovery code; six digits is an authenticator code — the + // same heuristic as the coordinator door. A recovery code is spent in the same conditional + // update that finds it, so it cannot be used twice, and a spent or unknown one is a counted + // failure like any other. + const looksRecovery = code.replace(/[^A-Za-z0-9]/g, "").length >= 10; + if (looksRecovery) { + const spent = await prisma.operatorRecoveryCode.updateMany({ + where: { operatorId: op.id, codeHash: hashRecoveryCode(code), usedAt: null }, + data: { usedAt: new Date() }, + }); + if (spent.count !== 1) { + fail("ops-login-ip:" + ip, WINDOW); + fail("ops-login-email:" + email, WINDOW); + logOperatorEvent({ operatorId: op.id, action: "ops:signin.failed", detail: "recovery", ip }); + return NextResponse.json({ error: "That recovery code isn’t right, or has already been used.", needCode: true }, { status: 401 }); + } + second = "recovery"; + } else if (!verifyOperatorCode(op, code)) { + fail("ops-login-ip:" + ip, WINDOW); + fail("ops-login-email:" + email, WINDOW); + logOperatorEvent({ operatorId: op.id, action: "ops:signin.failed", detail: "totp", ip }); + return NextResponse.json({ error: "That code isn’t right.", needCode: true }, { status: 401 }); + } else { + second = "totp"; + } + } + + await prisma.operator.update({ where: { id: op.id }, data: { lastSeenAt: new Date() } }); + await setOpsCookie(op.id, op.passwordHash, false); + logOperatorEvent({ operatorId: op.id, action: "ops:signin", detail: second ? `password+${second}` : "password", ip }); + return NextResponse.json({ ok: true, name: op.name }); +} diff --git a/app/api/ops/auth/logout/route.ts b/app/api/ops/auth/logout/route.ts new file mode 100644 index 0000000..70b0cbb --- /dev/null +++ b/app/api/ops/auth/logout/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; +import { clientIp } from "@/lib/ratelimit"; +import { clearOpsCookie, currentOperator, logOperatorEvent } from "@/lib/ops/session"; + +export const dynamic = "force-dynamic"; + +/* Ends the operator session. Reached by a plain form post from the console, so it answers with a + * redirect rather than JSON. Recorded in the trail when there was a session to end. */ +export async function POST(req: NextRequest) { + const op = await currentOperator(); + if (op) logOperatorEvent({ operatorId: op.id, action: "ops:signout", ip: clientIp(req.headers) }); + await clearOpsCookie(); + const url = req.nextUrl.clone(); + url.pathname = "/ops/login"; url.search = ""; + return NextResponse.redirect(url, { status: 303 }); +} diff --git a/app/api/ops/auth/sso/route.ts b/app/api/ops/auth/sso/route.ts new file mode 100644 index 0000000..410fb6d --- /dev/null +++ b/app/api/ops/auth/sso/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { clientIp } from "@/lib/ratelimit"; +import { safeOpsNext, verifyAccessJwt } from "@/lib/ops/cfAccess"; +import { logOperatorEvent, setOpsCookie } from "@/lib/ops/session"; + +export const dynamic = "force-dynamic"; + +/* Single sign-on entry. Reached when a browser arrives with a Cloudflare Access assertion but no + * operator session (the sign-in page hands off here). The assertion is verified fail-closed + * (lib/ops/cfAccess.ts), the verified email is mapped to an EXISTING, ACTIVE operator, and the + * ordinary tc_ops session is minted, marked `sso`. Never creates an operator: an email Access + * admits but the console does not know falls back to the password door. Every failure lands on + * /ops/login?sso=failed — never a bypass, never a loop (the sign-in page does not re-trigger SSO + * when ?sso is present). + * + * Relative Location on purpose: an absolute URL built from the request would carry the origin + * nginx sees (127.0.0.1:3000), not the public host. */ +function seeOther(location: string): NextResponse { + return new NextResponse(null, { status: 303, headers: { Location: location } }); +} + +export async function GET(req: NextRequest) { + const failed = seeOther("/ops/login?sso=failed"); + const next = safeOpsNext(req.nextUrl.searchParams.get("next")); + const ip = clientIp(req.headers); + + const email = await verifyAccessJwt(req.headers.get("cf-access-jwt-assertion")); + if (!email) return failed; + + const op = await prisma.operator.findUnique({ + where: { email }, + select: { id: true, inactive: true, passwordHash: true }, + }); + if (!op || op.inactive) { + // The trail cannot name an operator it does not have; the address goes in the detail, since + // "who Access let in that the console refused" is the fact worth keeping. + console.warn("[ops sso] no active operator for", email, "from", ip); + return failed; + } + + await setOpsCookie(op.id, op.passwordHash, true); + logOperatorEvent({ operatorId: op.id, action: "ops:signin.sso", ip }); + prisma.operator.update({ where: { id: op.id }, data: { lastSeenAt: new Date() } }).catch(() => {}); + return seeOther(next); +} diff --git a/app/api/ops/auth/totp/route.ts b/app/api/ops/auth/totp/route.ts new file mode 100644 index 0000000..626456a --- /dev/null +++ b/app/api/ops/auth/totp/route.ts @@ -0,0 +1,112 @@ +import { NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import QRCode from "qrcode"; +import { prisma } from "@/lib/db"; +import { sameOriginJson } from "@/lib/csrf"; +import { allow, clientIp } from "@/lib/ratelimit"; +import { hashRecoveryCode, newRecoveryCodes, newTotpSecret, otpauthUrl, totpVerify } from "@/lib/totp"; +import { currentOperator, logOperatorEvent } from "@/lib/ops/session"; +import { decryptOpsSecret, encryptOpsSecret } from "@/lib/ops/totp"; + +export const dynamic = "force-dynamic"; + +/* An operator's second factor: the same three steps as a coordinator's (app/api/2fa/route.ts), + * for the same reason — a secret stored the moment it is generated leaves an account + * half-enrolled if the person never finishes, and 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, switch it on, hand back recovery codes once. + * disable — password required; turning a factor off is a privileged act. + * regenerate — password required; new recovery codes, old ones gone. + * + * The secret is encrypted under the console's own key (lib/ops/totp.ts), never the product's. + * The QR is generated here as SVG, as the product does it: the secret never has to be handed to + * client-side code to render. */ +export async function GET() { + const op = await currentOperator(); + if (!op) return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + const left = await prisma.operatorRecoveryCode.count({ where: { operatorId: op.id, usedAt: null } }); + return NextResponse.json({ enabled: op.totpEnabled, recoveryLeft: left, viaSso: op.viaSso }); +} + +export async function POST(req: NextRequest) { + const csrf = sameOriginJson(req); + if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + const op = await currentOperator(); + if (!op) return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + + const ip = clientIp(req.headers); + if (!allow("ops-2fa-manage:" + op.id, 30, 15 * 60 * 1000)) { + return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 }); + } + + 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 o = await prisma.operator.findUnique({ + where: { id: op.id }, + select: { id: true, email: true, passwordHash: true, totpSecret: true, totpEnabledAt: true }, + }); + if (!o) return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + + if (action === "setup") { + if (o.totpEnabledAt) return NextResponse.json({ error: "Two-factor is already on for this account." }, { status: 400 }); + const secret = newTotpSecret(); + await prisma.operator.update({ where: { id: o.id }, data: { totpSecret: encryptOpsSecret(secret) } }); + const url = otpauthUrl(secret, o.email, "ThreadCount ops"); + const qr = await QRCode.toString(url, { type: "svg", margin: 1, width: 220, errorCorrectionLevel: "M" }); + logOperatorEvent({ operatorId: o.id, action: "ops:2fa.setup", ip }); + return NextResponse.json({ ok: true, secret, url, qr }); + } + + if (action === "enable") { + if (o.totpEnabledAt) return NextResponse.json({ error: "Two-factor is already on." }, { status: 400 }); + const secret = decryptOpsSecret(o.totpSecret); + if (!secret) return NextResponse.json({ error: "Start the setup again." }, { status: 400 }); + if (!totpVerify(secret, String(body.code ?? "").replace(/\s+/g, ""))) { + 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.operator.update({ where: { id: o.id }, data: { totpEnabledAt: new Date() } }); + await tx.operatorRecoveryCode.deleteMany({ where: { operatorId: o.id } }); + await tx.operatorRecoveryCode.createMany({ data: codes.map((c) => ({ operatorId: o.id, codeHash: hashRecoveryCode(c) })) }); + }); + logOperatorEvent({ operatorId: o.id, action: "ops:2fa.enable", ip }); + // The only time these are ever readable. Stored hashed, so there is no second chance. + return NextResponse.json({ ok: true, codes }); + } + + if (action === "disable") { + if (!o.totpEnabledAt) return NextResponse.json({ ok: true }); + const pw = String(body.password ?? ""); + if (!pw || !(await bcrypt.compare(pw, o.passwordHash))) { + return NextResponse.json({ error: "That password isn't right." }, { status: 401 }); + } + await prisma.$transaction(async (tx) => { + await tx.operator.update({ where: { id: o.id }, data: { totpEnabledAt: null, totpSecret: "" } }); + await tx.operatorRecoveryCode.deleteMany({ where: { operatorId: o.id } }); + }); + logOperatorEvent({ operatorId: o.id, action: "ops:2fa.disable", ip }); + return NextResponse.json({ ok: true }); + } + + if (action === "regenerate") { + if (!o.totpEnabledAt) return NextResponse.json({ error: "Two-factor isn't on." }, { status: 400 }); + const pw = String(body.password ?? ""); + if (!pw || !(await bcrypt.compare(pw, o.passwordHash))) { + return NextResponse.json({ error: "That password isn't right." }, { status: 401 }); + } + const codes = newRecoveryCodes(); + await prisma.$transaction(async (tx) => { + await tx.operatorRecoveryCode.deleteMany({ where: { operatorId: o.id } }); + await tx.operatorRecoveryCode.createMany({ data: codes.map((c) => ({ operatorId: o.id, codeHash: hashRecoveryCode(c) })) }); + }); + logOperatorEvent({ operatorId: o.id, action: "ops:2fa.regenerate", ip }); + return NextResponse.json({ ok: true, codes }); + } + + return NextResponse.json({ error: "Unknown action" }, { status: 400 }); +} diff --git a/app/api/ops/controls/route.ts b/app/api/ops/controls/route.ts new file mode 100644 index 0000000..66f92fc --- /dev/null +++ b/app/api/ops/controls/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sameOriginJson } from "@/lib/csrf"; +import { allow, clientIp } from "@/lib/ratelimit"; +import { currentOperator } from "@/lib/ops/session"; +import { ControlError, deleteFacility, PLAN_NOTE_MAX, planControl, resetDemoNow, setSwitch, type PlanAct } from "@/lib/ops/controls"; + +export const dynamic = "force-dynamic"; + +/* The console's one write endpoint. Each action is a function in lib/ops/controls.ts; this route + * checks the operator, shapes the input and turns a ControlError into a status. */ +export async function POST(req: NextRequest) { + const csrf = sameOriginJson(req); + if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + const op = await currentOperator(); + if (!op) return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + + const ip = clientIp(req.headers); + if (!allow("ops-controls:" + op.id, 30, 15 * 60 * 1000)) { + return NextResponse.json({ error: "Too many changes — try again in a few minutes." }, { status: 429 }); + } + + let body: Record; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + const s = (k: string, max = 200) => String(body[k] ?? "").slice(0, max); + const facilityId = s("facilityId", 40); + const idOk = /^[a-z0-9]{20,40}$/.test(facilityId); + + try { + switch (s("action", 40)) { + case "switch": { + const key = s("key", 40); + if (key !== "signupsDisabled" && key !== "demoDisabled" && key !== "plansLive") return NextResponse.json({ error: "Unknown switch" }, { status: 400 }); + await setSwitch(op, key, body.value === true, ip); + return NextResponse.json({ ok: true }); + } + case "demo.reset": + await resetDemoNow(op, ip); + return NextResponse.json({ ok: true }); + case "plan": { + if (!idOk) return NextResponse.json({ error: "Bad request" }, { status: 400 }); + const num = (k: string) => Number(body[k]); + let a: PlanAct; + switch (s("act", 20)) { + case "set": a = { act: "set", plan: s("plan", 40), planNote: s("planNote", PLAN_NOTE_MAX + 1), grandfathered: typeof body.grandfathered === "boolean" ? body.grandfathered : undefined }; break; + case "trial": a = { act: "trial", days: num("days") }; break; + case "paid": a = { act: "paid", months: num("months") }; break; + case "readonly": a = { act: "readonly", on: body.on === true }; break; + case "free": a = { act: "free" }; break; + default: return NextResponse.json({ error: "Unknown plan action" }, { status: 400 }); + } + await planControl(op, facilityId, a, ip); + return NextResponse.json({ ok: true }); + } + case "facility.delete": { + if (!idOk) return NextResponse.json({ error: "Bad request" }, { status: 400 }); + const r = await deleteFacility(op, facilityId, s("confirm", 200), s("code", 20), ip); + return NextResponse.json({ ok: true, deleted: r.name }); + } + default: + return NextResponse.json({ error: "Unknown action" }, { status: 400 }); + } + } catch (e) { + if (e instanceof ControlError) return NextResponse.json({ error: e.message }, { status: e.status }); + throw e; + } +} diff --git a/app/api/ops/reveal/route.ts b/app/api/ops/reveal/route.ts new file mode 100644 index 0000000..410ebc5 --- /dev/null +++ b/app/api/ops/reveal/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sameOriginJson } from "@/lib/csrf"; +import { allow, clientIp } from "@/lib/ratelimit"; +import { currentOperator } from "@/lib/ops/session"; +import { opsDb } from "@/lib/ops/db"; +import { grantReveal, REASON_MIN, REASON_MAX, REVEAL_MINUTES } from "@/lib/ops/reveal"; + +export const dynamic = "force-dynamic"; + +/* Open a thirty-minute window on one facility's coordinator contacts. The whole act — grant row, + * trail row, email — is lib/ops/reveal.ts; this route only checks the operator, the facility and + * the reason, and answers. The contacts are not in the response: the page reads them, through the + * reveal role, on its next render. */ +export async function POST(req: NextRequest) { + const csrf = sameOriginJson(req); + if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + const op = await currentOperator(); + if (!op) return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + + const ip = clientIp(req.headers); + // Ten an hour: a reveal is a considered act, and a run of them across facilities is exactly the + // pattern the limit exists to slow down. + if (!allow("ops-reveal:" + op.id, 10, 60 * 60 * 1000)) { + return NextResponse.json({ error: "Too many reveals in the last hour." }, { status: 429 }); + } + + let body: { facilityId?: unknown; reason?: unknown }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + const facilityId = String(body.facilityId ?? "").trim(); + const reason = String(body.reason ?? "").trim(); + if (!/^[a-z0-9]{20,40}$/.test(facilityId)) return NextResponse.json({ error: "Bad request" }, { status: 400 }); + if (reason.length < REASON_MIN) { + return NextResponse.json({ error: `Give a reason — at least ${REASON_MIN} characters. It goes in the trail and in the email.` }, { status: 400 }); + } + if (reason.length > REASON_MAX) return NextResponse.json({ error: `Keep the reason under ${REASON_MAX} characters.` }, { status: 400 }); + + // The facility's name and kind come from the ordinary role; nothing here reads a contact. + const f = await opsDb().facility.findUnique({ where: { id: facilityId }, select: { id: true, name: true, isDemo: true } }); + if (!f) return NextResponse.json({ error: "No such facility" }, { status: 404 }); + if (f.isDemo) return NextResponse.json({ error: "The demo facility has no real contacts to reveal." }, { status: 400 }); + + const r = await grantReveal({ operator: op, facilityId: f.id, facilityName: f.name, reason, ip }); + return NextResponse.json({ ok: true, minutes: REVEAL_MINUTES, expiresAt: r.expiresAt.toISOString(), mailed: r.mailed }); +} diff --git a/app/api/photo/[id]/route.ts b/app/api/photo/[id]/route.ts new file mode 100644 index 0000000..ea7f060 --- /dev/null +++ b/app/api/photo/[id]/route.ts @@ -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", + }, + }); +} diff --git a/app/api/requests/route.ts b/app/api/requests/route.ts new file mode 100644 index 0000000..9898440 --- /dev/null +++ b/app/api/requests/route.ts @@ -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(); + 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(), + })), + }); +} diff --git a/app/api/rev/route.ts b/app/api/rev/route.ts new file mode 100644 index 0000000..95a3819 --- /dev/null +++ b/app/api/rev/route.ts @@ -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" } }); +} diff --git a/app/api/sso/route.ts b/app/api/sso/route.ts new file mode 100644 index 0000000..d543340 --- /dev/null +++ b/app/api/sso/route.ts @@ -0,0 +1,151 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { currentUser } from "@/lib/session"; +import { sameOriginJson } from "@/lib/csrf"; +import { allow, clientIp } from "@/lib/ratelimit"; +import { recordAudit } from "@/lib/audit"; +import { bumpRev } from "@/lib/ops"; +import { createOrUpdateConnection, deleteConnection, domainTakenBy, getConnection, normaliseDomain, ssoConfigured, SsoError } from "@/lib/sso"; + +export const dynamic = "force-dynamic"; + +/* An admin's single sign-on settings for their own facility. + * + * GET the switches, the registered domains, and whether the broker holds a connection; + * POST connect: hand the IdP metadata to the broker, then — and only then — switch SSO on; + * PATCH the switches and domains, with SSO already connected; + * DELETE disconnect: remove the connection from the broker and switch everything off. + * + * The IdP metadata never touches this database; the broker keeps it. The switches live on the + * facility row, so the sign-in routes can read them without asking the broker. Admin only, never + * the demo, and 404 throughout when no broker is configured — the feature then does not exist. */ +function notHere() { return NextResponse.json({ error: "Single sign-on is not available on this server." }, { status: 404 }); } + +async function gate(req: NextRequest, json: boolean) { + if (!ssoConfigured()) return { res: notHere() } as const; + const csrf = sameOriginJson(req, json); + if (csrf) return { res: NextResponse.json({ error: csrf }, { status: 403 }) } as const; + const user = await currentUser(); + if (!user) return { res: NextResponse.json({ error: "Not signed in" }, { status: 401 }) } as const; + if (user.role !== "ADMIN") return { res: NextResponse.json({ error: "Admins only" }, { status: 403 }) } as const; + if (user.isDemo) return { res: NextResponse.json({ error: "Not available in the demo." }, { status: 403 }) } as const; + if (!allow("sso-admin:" + user.id, 30, 15 * 60 * 1000)) return { res: NextResponse.json({ error: "Too many changes — try again in a few minutes." }, { status: 429 }) } as const; + return { user } as const; +} + +export async function GET() { + if (!ssoConfigured()) return notHere(); + const user = await currentUser(); + if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + if (user.role !== "ADMIN") return NextResponse.json({ error: "Admins only" }, { status: 403 }); + const f = await prisma.facility.findUniqueOrThrow({ where: { id: user.facilityId }, select: { ssoEnabled: true, ssoRequired: true, ssoStaff: true, ssoDomains: true } }); + let connected: boolean | null = null, idp: string | null = null; + try { + const c = await getConnection(user.facilityId); + connected = !!c; + idp = c?.idpMetadata?.provider || c?.idpMetadata?.entityID || null; + } catch { + connected = null; // the broker could not be reached; the switches still say what they say + } + return NextResponse.json({ enabled: f.ssoEnabled, required: f.ssoRequired, staff: f.ssoStaff, domains: f.ssoDomains, connected, idp }); +} + +export async function POST(req: NextRequest) { + const g = await gate(req, true); + if ("res" in g) return g.res; + const { user } = g; + let body: { metadataUrl?: unknown; metadataXml?: unknown; domains?: unknown }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + const metadataUrl = String(body.metadataUrl ?? "").trim().slice(0, 2000); + const metadataXml = String(body.metadataXml ?? "").trim().slice(0, 200_000); + if (!metadataUrl && !metadataXml) return NextResponse.json({ error: "Paste your identity provider's metadata URL or its XML." }, { status: 400 }); + if (metadataUrl) { + let u: URL; + try { u = new URL(metadataUrl); } catch { return NextResponse.json({ error: "That metadata URL isn't a valid URL." }, { status: 400 }); } + // Fetched by the broker server-side: only https, or a document could be swapped in transit. + if (u.protocol !== "https:") return NextResponse.json({ error: "The metadata URL must start with https://." }, { status: 400 }); + } + const domains = await checkDomains(body.domains, user.facilityId); + if ("error" in domains) return NextResponse.json({ error: domains.error }, { status: 400 }); + if (domains.list.length === 0) return NextResponse.json({ error: "Add at least one email domain — it is how your people reach your sign-in." }, { status: 400 }); + + const f = await prisma.facility.findUniqueOrThrow({ where: { id: user.facilityId }, select: { name: true } }); + try { + await createOrUpdateConnection({ facilityId: user.facilityId, facilityName: f.name, metadataUrl: metadataUrl || undefined, metadataXml: metadataXml || undefined }); + } catch (e) { + if (e instanceof SsoError) return NextResponse.json({ error: e.message }, { status: 502 }); + throw e; + } + // Only once the broker holds a real connection does the switch go on. + await prisma.facility.update({ where: { id: user.facilityId }, data: { ssoEnabled: true, ssoDomains: domains.list } }); + recordAudit(user, "settings.sso.connect", { domains: domains.list, via: metadataUrl ? "url" : "xml" }, clientIp(req.headers)); + await bumpRev(user.facilityId); + return NextResponse.json({ ok: true, enabled: true, domains: domains.list }); +} + +export async function PATCH(req: NextRequest) { + const g = await gate(req, true); + if ("res" in g) return g.res; + const { user } = g; + let body: { required?: unknown; staff?: unknown; domains?: unknown }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + const f = await prisma.facility.findUniqueOrThrow({ where: { id: user.facilityId }, select: { ssoEnabled: true } }); + if (!f.ssoEnabled) return NextResponse.json({ error: "Connect your identity provider first." }, { status: 400 }); + const data: { ssoRequired?: boolean; ssoStaff?: boolean; ssoDomains?: string[] } = {}; + if (body.required !== undefined) { + data.ssoRequired = body.required === true; + if (data.ssoRequired) { + // Requiring SSO with nobody left holding a password is a facility nobody can enter the day + // the identity provider is down. Somebody — an admin — keeps a key. + const keys = await prisma.user.count({ where: { facilityId: user.facilityId, role: "ADMIN", inactive: false, ssoBreakGlass: true } }); + if (keys === 0) return NextResponse.json({ error: "Mark at least one admin as break-glass first — they keep a working password for the day the identity provider is down." }, { status: 400 }); + } + } + if (body.staff !== undefined) data.ssoStaff = body.staff === true; + if (body.domains !== undefined) { + const domains = await checkDomains(body.domains, user.facilityId); + if ("error" in domains) return NextResponse.json({ error: domains.error }, { status: 400 }); + if (domains.list.length === 0) return NextResponse.json({ error: "Keep at least one email domain." }, { status: 400 }); + data.ssoDomains = domains.list; + } + await prisma.facility.update({ where: { id: user.facilityId }, data }); + recordAudit(user, "settings.sso.update", data, clientIp(req.headers)); + await bumpRev(user.facilityId); + return NextResponse.json({ ok: true, ...data }); +} + +export async function DELETE(req: NextRequest) { + const g = await gate(req, false); + if ("res" in g) return g.res; + const { user } = g; + try { + await deleteConnection(user.facilityId); + } catch (e) { + if (e instanceof SsoError) return NextResponse.json({ error: e.message }, { status: 502 }); + throw e; + } + // Everything off, whatever the broker said: the button's job is to end SSO here. + await prisma.facility.update({ where: { id: user.facilityId }, data: { ssoEnabled: false, ssoRequired: false, ssoStaff: false } }); + recordAudit(user, "settings.sso.disconnect", {}, clientIp(req.headers)); + await bumpRev(user.facilityId); + return NextResponse.json({ ok: true, enabled: false }); +} + +/** Up to ten well-formed domains, each owned by no other facility. */ +async function checkDomains(raw: unknown, facilityId: string): Promise<{ list: string[] } | { error: string }> { + const arr = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[\s,]+/) : []; + const list: string[] = []; + for (const r of arr) { + if (typeof r !== "string" || !r.trim()) continue; + const d = normaliseDomain(r); + if (!d) return { error: `“${String(r).slice(0, 60)}” isn't a domain. Use the part after the @ in your work addresses, like health.example.` }; + if (["gmail.com", "outlook.com", "hotmail.com", "yahoo.com", "icloud.com", "live.com"].includes(d)) return { error: `${d} is a public mail service, not your facility's — anyone could sign up there.` }; + if (!list.includes(d)) list.push(d); + } + if (list.length > 10) return { error: "Ten domains at most." }; + for (const d of list) { + const owner = await domainTakenBy(d, facilityId); + if (owner) return { error: `${d} is already registered by another facility.` }; + } + return { list }; +} diff --git a/app/api/staff/activate/route.ts b/app/api/staff/activate/route.ts new file mode 100644 index 0000000..87adbe5 --- /dev/null +++ b/app/api/staff/activate/route.ts @@ -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}` }); +} diff --git a/app/api/staff/decide/route.ts b/app/api/staff/decide/route.ts new file mode 100644 index 0000000..1309061 --- /dev/null +++ b/app/api/staff/decide/route.ts @@ -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>; + 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 }); +} diff --git a/app/api/staff/login/route.ts b/app/api/staff/login/route.ts new file mode 100644 index 0000000..1419734 --- /dev/null +++ b/app/api/staff/login/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sameOriginJson } from "@/lib/csrf"; +import { clientIp } from "@/lib/ratelimit"; +import { signInStaff, staffThrottled } from "@/lib/staffauth"; +import { verifyTurnstile } from "@/lib/turnstile"; + +export const dynamic = "force-dynamic"; + +/* The staff app's own door: the printed slip, the Play app's welcome, and /my/signin. + * + * The Log in box on the website reaches the same register through lib/staffauth.ts, so what counts + * as a match, what a deactivated record is told, and what lands in the audit trail are decided in + * one place for both. This route is the HTTP shape of it: the origin check, the security check, and + * the throttle asked in that order. */ +export async function POST(req: NextRequest) { + const csrf = sameOriginJson(req); + if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + + let body: { email?: unknown; password?: unknown; cfToken?: unknown }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160); + const password = String(body.password ?? "").slice(0, 200); + + const ip = clientIp(req.headers); + // Asked before the security check, because a Turnstile token is good for one use and somebody who + // is already throttled should not spend theirs to be told so. + if (staffThrottled(email, ip)) { + return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 }); + } + if (!email || !password) return NextResponse.json({ error: "Enter your email and password." }, { status: 400 }); + + // The same bot check the coordinator door has. A ward account opens one person's uniform record, + // and a manager's opens the approvals queue, so leaving this to the in-memory throttles alone + // meant a list of hospital addresses and enough patience was the whole attack. + const cfErr = await verifyTurnstile(body.cfToken, ip); + if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 }); + + // `true`: this is the register's own door, so an address with no account here is a plain wrong + // answer and is counted as one. + const r = await signInStaff(email, password, ip, true); + if (r.kind === "ok") return NextResponse.json({ ok: true, name: r.name }); + if (r.kind === "error") return NextResponse.json({ error: r.error }, { status: r.status }); + // Unreachable at this door: `none` is only returned when the caller asked not to be counted. + return NextResponse.json({ error: "Email or password doesn’t match." }, { status: 401 }); +} diff --git a/app/api/staff/logout/route.ts b/app/api/staff/logout/route.ts new file mode 100644 index 0000000..1516be5 --- /dev/null +++ b/app/api/staff/logout/route.ts @@ -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 }); +} diff --git a/app/api/staff/mutate/route.ts b/app/api/staff/mutate/route.ts new file mode 100644 index 0000000..5f314f2 --- /dev/null +++ b/app/api/staff/mutate/route.ts @@ -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; + 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 }); + } +} diff --git a/app/api/subscribe/route.ts b/app/api/subscribe/route.ts new file mode 100644 index 0000000..22f0558 --- /dev/null +++ b/app/api/subscribe/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { allow, clientIp } from "@/lib/ratelimit"; +import { sameOriginJson } from "@/lib/csrf"; +import { verifyTurnstile } from "@/lib/turnstile"; + +export const dynamic = "force-dynamic"; + +/* Newsletter sign-up. + * + * ThreadCount keeps two promises that shape this endpoint. The contact form says, at the point of + * collection, "No mailing list, no follow-up sequence" — so nothing that arrives through the + * contact form ever reaches this list, and the two paths share no code and no storage. And the + * list is double opt-in: this handler only ever creates an *unconfirmed* subscriber, and Listmonk + * emails a confirmation link that the person has to click before they can be sent anything. + * + * It posts to ThreadCount's own Listmonk (lists.threadcount.tech), which is a separate instance + * from ClearAudit's: Listmonk has a single global from-address, so sharing one would have sent + * ThreadCount's confirmation emails from ClearAudit and failed SPF/DKIM alignment for this domain. + * + * The list uuid is not a secret — it is designed to sit in a public subscription form — so it is + * committed rather than left to an env var that a build could forget. */ +const LIST_UUID = process.env.LISTMONK_LIST_UUID || "734e9011-5fd5-48a7-b0ae-4ea0e1deb972"; +const LISTMONK = process.env.LISTMONK_URL || "https://lists.threadcount.tech"; + +const str = (v: unknown, max: number) => String(v ?? "").trim().slice(0, max); + +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("subscribe:" + ip, 5, 60 * 60 * 1000) || !allow("subscribe-day:" + ip, 20, 24 * 60 * 60 * 1000)) { + return NextResponse.json({ error: "That's a few attempts in a short time. Try again later." }, { status: 429 }); + } + + let b: Record; + try { b = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + + // Honeypot, same as the contact form: a real person never fills this in. + if (str(b.company, 100)) return NextResponse.json({ ok: true }); + + const email = str(b.email, 160).toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return NextResponse.json({ error: "That email address doesn't look right." }, { status: 400 }); + } + + const cfErr = await verifyTurnstile(b.cfToken, ip); + if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 }); + + // Listmonk's public subscription handler. It creates the subscriber as unconfirmed and sends the + // opt-in email itself, which is why this endpoint never needs an admin token. + const form = new URLSearchParams({ email, name: "", l: LIST_UUID }); + + try { + const r = await fetch(`${LISTMONK}/subscription/form`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: form.toString(), + redirect: "manual", // success is a 302 back to a thank-you page + signal: AbortSignal.timeout(8000), + }); + if (r.status >= 500) { + return NextResponse.json({ error: "Sign-up is unavailable for a moment — please try again shortly." }, { status: 502 }); + } + } catch { + return NextResponse.json({ error: "Sign-up is unavailable for a moment — please try again shortly." }, { status: 502 }); + } + + // Deliberately the same answer whether or not the address was already on the list: otherwise + // this endpoint would confirm to a stranger who is subscribed. + return NextResponse.json({ ok: true }); +} diff --git a/app/app/activity/page.tsx b/app/app/activity/page.tsx new file mode 100644 index 0000000..db4f526 --- /dev/null +++ b/app/app/activity/page.tsx @@ -0,0 +1,277 @@ +"use client"; +/* Who changed what. + * + * Reads from its own endpoint rather than the snapshot: the trail grows without limit and putting + * it in the snapshot would make every page in the app heavier forever, to serve a screen almost + * nobody opens on an ordinary day. + * + * It shows ids rather than names on purpose — see lib/audit.ts. The id is the handle for going and + * looking at the record; copying its contents in here would quietly build a second, unmanaged copy + * of the staff register. */ +import { useCallback, useEffect, useState } from "react"; +import { useSnap } from "@/lib/client"; +import { Empty, LiveRegion, PageHead } from "@/components/ui"; +import { csvEsc, csvOf, facilityDate, formatInZone } from "@/lib/compute"; +import { downloadCsv } from "@/lib/print"; + +type Event = { id: string; at: string; who: string; op: string; target: string }; + +/* Operation names are written for the code. These are written for whoever is reading the log at + the point somebody asks what happened. */ +const LABELS: Record = { + "issue.create": "Issued garments", + "issue.return": "Recorded a return", + "issue.exchange": "Exchanged a size", + "issue.delete": "Deleted an issue", + "issue.receipt": "Attached a signed receipt", + "stocktake.apply": "Committed a stocktake", + "stock.reorder": "Changed a par level", + "stock.moves": "Adjusted stock", + "stock.orderFlagged": "Raised an order from low stock", + "catalog.add": "Added a garment", + "catalog.update": "Edited a garment", + "catalog.delete": "Deleted a garment", + "catalog.duplicate": "Duplicated a garment", + "catalog.bulk": "Bulk-changed the catalogue", + "catalog.variantAdd": "Added a size", + "catalog.removeSize": "Removed a size", + "barcode.bind": "Bound a barcode", + "barcode.unbind": "Unbound a barcode", + "order.create": "Created an order", + "order.receive": "Received an order", + "order.status": "Changed an order’s status", + "order.update": "Edited an order", + "order.duplicate": "Duplicated an order", + "order.lineAdd": "Added an order line", + "order.lineQty": "Changed an order quantity", + "order.lineRemove": "Removed an order line", + "staff.save": "Added or edited a staff record", + "staff.patch": "Edited a staff record", + "staff.delete": "Deleted a staff record", + "dept.save": "Edited a department", + "dept.delete": "Deleted a department", + "supplier.add": "Added a supplier", + "supplier.update": "Edited a supplier", + "supplier.remove": "Removed a supplier", + "location.save": "Added or edited a location", + "location.delete": "Deleted a location", + "location.place": "Placed stock on a shelf", + "approval.add": "Recorded a manager's approval", + "approval.remove": "Removed a manager's approval", + "alteration.add": "Logged an alteration", + "alteration.advance": "Advanced an alteration", + "alteration.remove": "Removed an alteration", + "handin.add": "Recorded a hand-in", + "pickup.contacted": "Marked a pickup contacted", + "pickup.pickedUp": "Marked a pickup collected", + "pickup.deliver": "Delivered to a ward", + "request.raise": "Raised a request for somebody", + "request.pick": "Started picking a request", + "request.hold": "Held a request at the counter", + "request.round": "Put a request on the ward round", + "request.collected": "Handed a request over", + "request.reply": "Wrote back about a request", + "request.reassign": "Sent a request to a different approver", + "request.withdraw": "Withdrew a request", + "damage.handedIn": "Took a damaged garment back", + "dispute.resolve": "Closed a record query", + "notice.set": "Changed the ward notice", + "kitcheck.open": "Started a kit check", + "kitcheck.close": "Closed a kit check", + "waitlist.offer": "Offered a waiting size", + "staff.selfCode": "Made a staff-app activation code", + "staff.selfClear": "Cancelled an activation code", + "staff.selfUnlink": "Removed somebody’s staff-app access", + "users.add": "Invited a user", + "users.update": "Changed a user", + "users.remove": "Removed a user", + "settings.update": "Changed settings", + "import.rows": "Imported data", + "backup.restore": "Restored a backup", + "data.reset": "Reset facility data", + "data.wipeActivity": "Wiped activity history", + "me.password": "Changed their own password", + "me.profile": "Edited their own profile", + "me.deleteAccount": "Deleted their own account", + + /* Signing in and out, and the second factor. + * + * The page promises "every change made in this facility", and who reached the account is part of + * that — a stock adjustment nobody disputes reads differently next to a run of failed sign-ins + * from an address nobody recognises. Without these lines the trail rendered the raw op names. */ + "auth:signin": "Signed in", + "auth:signin.failed": "A failed sign-in", + "auth:signin.refused": "Sign-in refused (deactivated)", + "auth:signout": "Signed out", + "auth:signup": "Created the facility", + "auth:password.reset": "Set a new password from a reset link", + "2fa:setup": "Started two-factor setup", + "2fa:enable": "Turned two-factor on", + "2fa:disable": "Turned two-factor OFF", + "2fa:regenerate": "Made new recovery codes", + + /* The staff app. Every one of these is somebody on a ward changing something the linen room has + * to live with, so they belong in the same trail rather than a second one nobody opens. */ + "staff:signin": "Signed in to the staff app", + "staff:signin.failed": "A failed staff-app sign-in", + "staff:signin.refused": "Staff-app sign-in refused (deactivated)", + "staff:signout": "Signed out of the staff app", + "staff:activate": "Claimed their own record", + "staff:request.create": "Raised a uniform request", + "staff:request.approve": "Approved a request (in the app)", + "staff:request.decline": "Declined a request (in the app)", + "staff:request.approve.email": "Approved a request (email link)", + "staff:request.decline.email": "Declined a request (email link)", + "staff:request.message": "Wrote about a request", + "staff:round.sign": "Signed for a ward delivery", + "staff:round.claim": "Confirmed a ward bag was collected", + "staff:damage.report": "Reported damage", + "staff:dispute.raise": "Said their record is wrong", + "staff:waitlist.join": "Joined a waiting list", + "staff:waitlist.leave": "Left a waiting list", + "staff:waitlist.accept": "Took up a waitlist offer", + "staff:kit.answer": "Answered a kit check", + "staff:account.password": "Changed their own staff-app password", +}; + +/** Operations worth noticing in a list of hundreds. */ +const NOTABLE = new Set([ + "catalog.delete", "catalog.removeSize", "staff.delete", "dept.delete", "location.delete", "supplier.remove", + "users.add", "users.remove", "users.update", "settings.update", "backup.restore", + "data.reset", "data.wipeActivity", "me.deleteAccount", "catalog.bulk", + // Turning the second factor off weakens every account in the facility, and a refused sign-in is + // somebody with a password trying to get in after their access was taken away. Both are worth + // catching an eye in a list of hundreds. + "2fa:disable", "auth:signin.refused", "staff:signin.refused", + // The two ends of staff-app access: selfCode mints a credential that opens somebody's record, + // selfUnlink takes their account away. Both are the linen room reaching into a person's access + // rather than into stock, which is exactly what an admin is looking for when they open this. + "staff.selfCode", "staff.selfUnlink", +]); + +/* Stamped in the facility's own zone, not the browser's. An audit trail read on a laptop that is + travelling, or served by a machine set to UTC, has to agree with the clock on the linen-room wall + or the times are worse than useless in a dispute. */ +function when(iso: string, tz: string) { + return formatInZone(iso, tz, { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", hour12: false }); +} + +export default function Activity() { + const { s, isAdmin } = useSnap(); + const [events, setEvents] = useState([]); + const [before, setBefore] = useState(null); + const [more, setMore] = useState(false); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(""); + + const load = useCallback(async (cursor: string | null) => { + setLoading(true); + try { + const r = await fetch("/api/activity" + (cursor ? `?before=${encodeURIComponent(cursor)}` : "")); + const j = await r.json(); + if (!r.ok) { setErr(j.error || "Couldn’t load the log."); return; } + setEvents((prev) => (cursor ? [...prev, ...j.events] : j.events)); + setBefore(j.nextBefore); + setMore(!!j.nextBefore); + } catch { + setErr("Couldn’t load the log."); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { if (isAdmin) void load(null); else setLoading(false); }, [isAdmin, load]); + + /* The file is what is on screen, and nothing more — in two senses. + * + * The log is paged, so what comes out is what has been loaded. If the question reaches further + * back than the screen does, press Load older first and export again; the file states on its + * face how far it goes, so a first page can never be handed over as though it were the whole + * trail. And no column appears that the screen does not show: the trail also records the address + * each change came from, which is why the endpoint never sends it to this page, and a file that + * leaves the building by email is the last place to start handing that around. */ + function exportCsv() { + /* Full date, and seconds — neither of which the table needs, because you read it in order. + A spreadsheet gets re-sorted the moment it lands: "9 Sep, 14:32" sorts as text into nonsense + and carries no year at all, and two changes inside the same minute would lose the order they + happened in, which is the whole question when a figure is disputed. The zone is the + facility's, the same as the screen, and it is named at the top of the file so a copy opened + in another state is not quietly read as local time. */ + const stamp = (iso: string) => + `${facilityDate(iso, s.tz)} ${formatInZone(iso, s.tz, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, hourCycle: "h23" })}`; + /* The four headings are the table's, and mean the same four things: What is the plain-English + label the screen shows rather than the op name behind it, and Record is the identifier + exactly as shown — left blank rather than carrying the screen's dash, which in a spreadsheet + cell is only noise. */ + const reach = more ? `${events.length} (older events not loaded)` : `${events.length} (the whole log)`; + downloadCsv(`threadcount-activity-${s.today}.csv`, + `Activity log,${csvEsc(s.today)}\nTimes shown in,${csvEsc(s.tz)}\nEvents in this file,${csvEsc(reach)}\n\n` + + csvOf(["When", "Who", "What", "Record"], events.map((e) => [stamp(e.at), e.who, LABELS[e.op] || e.op, e.target]))); + } + + if (!isAdmin) { + return ( +
+ + Only an admin can read the change log. +
+ ); + } + + return ( +
+ + + + + + + {/* The log is one block with its own head and foot rather than a table adrift on the page: + how far back it reaches is the first thing anybody asks of it, so the count sits on the + block itself and Load older sits under the same border as the rows it extends. */} +
+
+ Change log + {events.length} shown{more ? " · older events not loaded" : ""} +
+
+ + + + + + {events.map((e) => { + const notable = NOTABLE.has(e.op); + return ( + + + + {/* A line worth stopping on is marked as well as coloured. The accent is the + brand — it is the primary button and the current menu item — so a second red + in a list of hundreds is a guess; the mark beside the words is what actually + says "this one". Decoration, so it is hidden from a screen reader: the + wording of the line is the message. */} + + + + ); + })} + {!events.length && !loading && ( + + )} + +
WhenWhoWhatRecord
{when(e.at, s.tz)}{e.who} + {notable && {e.target || "—"}
Nothing recorded yet.
+
+
+ {more && } + {events.length} shown{more ? " — Export CSV writes these, so load the older events first if the file has to reach further back." : ". Export CSV writes the whole log."} +
+
+
+ ); +} diff --git a/app/app/help/page.tsx b/app/app/help/page.tsx new file mode 100644 index 0000000..be232a0 --- /dev/null +++ b/app/app/help/page.tsx @@ -0,0 +1,116 @@ +"use client"; +/* Help: the rules and routines a coordinator needs to know once, written down in one place so the + * working screens don't have to carry them. The owner took the explanations off every screen and + * asked for anything worth keeping to live here instead. + * + * Every figure is read from this facility's own settings rather than written in, so the page can't + * quote a number the facility has changed. The import rules are the templates' own notes, so they + * can't drift from what the importer accepts. */ +import { useSnap } from "@/lib/client"; +import { PageHead } from "@/components/ui"; +import { CSV_TEMPLATES } from "@/lib/csv"; +import { FTE_SETS, SLIP_DAYS } from "@/lib/compute"; +import { SET_GARMENTS, setsCap, setsOnStart } from "@/lib/sets"; + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +} + +const list = { margin: 0, paddingLeft: "1.2em", display: "grid", gap: "var(--space-1)" } as const; + +export default function Help() { + const { s } = useSnap(); + const cap = setsCap(s.settings.capSets); + const start = Math.min(cap, setsOnStart(s.settings.initialSets)); + // The table as the form lists it: a full-timer's figure first, down to the smallest. + const table = Object.entries(FTE_SETS).filter(([, n]) => n !== null) as [string, number][]; + + return ( + <> + + +
+
    +
  • Up to {cap} sets at any time — {cap} tops and {cap} pairs of trousers. The same for every staff group, nursing included.
  • +
  • It counts everything issued and not handed in or returned, plus anything on order for them, waiting at the counter, or approved and not yet collected. Pre-loved garments count.
  • +
  • Garments that aren't part of a set — fleeces, jackets, maternity wear — have their own ceiling of {cap}.
  • +
  • It isn't a yearly allowance and nothing resets in July. At the ceiling, the next garment comes by handing one in first, or on a coordinator's override, which is recorded.
  • +
  • Change the figure under Settings → General.
  • +
+
+ +
+

Each staff group is on one route, chosen under Settings → Staff groups. All three stop at the same {cap} sets.

+
    +
  • FTE table — the hours someone works propose their starting kit: {table.map(([fte, n]) => `${fte} FTE ${n}`).join(", ")} sets; a casual is at the manager's discretion. A manager may sign for more.
  • +
  • Starting kit — {start} sets on the first day ({start * SET_GARMENTS} garments), then more as needed. Nothing has to be handed back first.
  • +
  • Manager approval — no starting kit; the manager approves each set.
  • +
  • A group can't be on two routes, and a group with people in it can't be removed — rename it instead.
  • +
+
+ +
+

“Items (FY)” on Reports and “drawn since July” on Issue Stock count what someone has drawn since 1 July. They feed the reports and the monthly exceptions list, and never limit what the counter issues. Groups on the FTE table aren't measured against one.

+
+ +
+
    +
  • Handing a garment in frees room at the counter straight away, whether or not the credit box is ticked.
  • +
  • The credit tick adds the good garments back to the yearly figure and to the manager's approval. Pre-loved garments earn neither.
  • +
  • Good garments join the pre-loved pool and are reissued free; rags are counted for disposal.
  • +
+
+ +
+

A garment's type decides how it counts. Tops and trousers are each half a set; every other type counts toward the separate ceiling. A type typed in by hand that isn't on the list counts toward no set, so pick from the list.

+

Each garment is tagged for the staff groups that wear it, or for all groups. Staff can only request their own groups' garments, and the counter needs a coordinator's override, which is recorded, to issue anyone a garment outside their group.

+

A garment is also men's, women's or unisex. Somebody is offered the cut set as their Uniform style plus everything unisex; blank means every style until a coordinator sets it, and the counter needs the same override, also recorded, to issue anyone another cut.

+
+ +
+
    +
  • Generate a code on the staff record and hand them the slip. A code works once and expires after {SLIP_DAYS} days.
  • +
  • Record their manager first, under Manager's approval on the staff record — nobody can raise a request without one.
  • +
+
+ +
+
    +
  • A request goes to the person's manager — the same person who signs their paper order form.
  • +
  • A manager can raise requests for the people who report to them; those go to the manager above. With nobody above, the request waits under Ward Requests → Needs an approver.
  • +
  • Nobody approves a request they raised for somebody else.
  • +
  • Anyone can be set as their own manager; what they approve for themselves is marked Self-approved.
  • +
+
+ +
+

A count in progress is saved in this browser only, under your sign-in. It survives a reload, but not a move to another computer or the phone — finish a count where you started it.

+
+ +
+

Settings → Data imports each list from a CSV file. The rules for each:

+
    + {Object.entries(CSV_TEMPLATES).map(([k, t]) =>
  • {t.name} — {t.note}
  • )} +
+

The Staff Register's Export writes the same columns, so a ward's list can go to its manager, come back with Manager number filled in, and be imported again. The Approver name column is only for checking and is ignored on import.

+
+ +
+

One debit line per cost centre, priced at each garment's cost on the day it was issued. Finance posts the balancing credit.

+
+ +
+
    +
  • You — password resets, and updates you've subscribed to.
  • +
  • Staff — only about their own requests, once they've set up the staff app.
  • +
  • Managers — the link to approve or decline a request.
  • +
+
+ + ); +} diff --git a/app/app/issue/page.tsx b/app/app/issue/page.tsx new file mode 100644 index 0000000..88c9659 --- /dev/null +++ b/app/app/issue/page.tsx @@ -0,0 +1,501 @@ +"use client"; +import Link from "next/link"; +import { useMemo, useState, useEffect } from "react"; +import { useDerived, useSnap } from "@/lib/client"; +import { PageHead, Empty, LiveRegion } from "@/components/ui"; +import { BindDialog, HandInDialog, ReturnDialog, openSlip, printCreditSlip } from "@/components/dialogs"; +import Camera from "@/components/Camera"; +import { SET_GARMENTS, allowance, approvalRemaining, bcParse, capCheck, capState, ccOf, entUsed, fmtDate, garmentForGroup, UNIFORM_STYLE_EITHER, garmentForStyle, genderLabel, groupBucket, groupsLabel, heldByStaff, inBucket, initialGarments, initialRemaining, isKit, isNursing, isPantItem, isTopItem, key, label, longLabel, money, onhand, openApproval, plOf, setsCap, setsHeld, staffName, type GarmentCounts, type IssueRec } from "@/lib/compute"; + +// src null = both shelf and pre-loved stock exist, the coordinator must pick one. +type CartLine = { itemId: string; si: number; qty: number; src: "stock" | "order" | "preloved" | null }; + +const count = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; + +/** Where somebody stands against the ceiling before anything goes in the bag — for the tag beside + * their name, and the line under it in the search results. + * + * Asked of the rule by putting one more of each kind in front of it, rather than by comparing their + * sets with six here. The ceiling bites on tops and on pairs separately, so somebody holding six + * tops and two pairs is "two sets" and is still refused the next top; a tag that read their sets + * told the coordinator OK, and the counter then turned the person away. `full` names each kind the + * next one of would be refused. Past the ceiling already, anything at all would be, so nothing is + * singled out. */ +function standing(held: GarmentCounts, capSets: number) { + const refused = (adding: { tops?: number; pants?: number; other?: number }) => capState({ held, adding, capSets }).over; + const over = refused({}); + const full = over ? [] : ([refused({ tops: 1 }) && "tops", refused({ pants: 1 }) && "pairs", refused({ other: 1 }) && "garments outside a set"].filter(Boolean) as string[]); + const room = full.length ? `no room for more ${full.length > 1 ? `${full.slice(0, -1).join(", ")} or ${full[full.length - 1]}` : full[0]}` : ""; + return { over, full, room, tag: over ? "OVER" : full.length ? "AT LIMIT" : "OK" }; +} + +export default function IssuePage() { + const { s, mutate } = useSnap(); + const { L, byId, staffById } = useDerived(); + const [staffQ, setStaffQ] = useState(""); + const [selId, setSelId] = useState(null); + const [scan, setScan] = useState(""); + const [qaQ, setQaQ] = useState(""); + const [cart, setCart] = useState([]); + const [override, setOverride] = useState(false); + const [apDeduct, setApDeduct] = useState(null); + const [issueMsg, setIssueMsg] = useState(""); + const [cam, setCam] = useState(false); + useEffect(() => { const h = () => { setCamMsg(""); setCam(true); }; window.addEventListener("tc-scan", h); return () => window.removeEventListener("tc-scan", h); }, []); + const [camMsg, setCamMsg] = useState(""); + const [bind, setBind] = useState(""); + const [ret, setRet] = useState(null); + const [busy, setBusy] = useState(false); + const [handin, setHandin] = useState(false); + + const sel = selId ? staffById[selId] : undefined; + // An override is a coordinator's decision about one person and one bag, so the tick goes the + // moment either of them changes. Left standing, a tick given for somebody past six rode along to + // the next name clicked, and that person's ordinary collection went on the record as a rule + // somebody bent. Cleared as the page draws rather than afterwards, so the new bag is never on + // screen, even for an instant, with the old tick behind it. + const bagKey = selId ? `${selId}|${cart.map((c) => `${c.itemId}:${c.si}:${c.qty}:${c.src}`).join(",")}` : ""; + const [tickedFor, setTickedFor] = useState(bagKey); + if (tickedFor !== bagKey) { setTickedFor(bagKey); setOverride(false); } + function addToCart(itemId: string, si: number) { + setCart((c) => { + const f = c.find((x) => x.itemId === itemId && x.si === si); + if (f) return c.map((x) => x === f ? { ...x, qty: x.qty + 1 } : x); + const oh = onhand(s, L, key(itemId, si)), pl = plOf(s, key(itemId, si)); + return [...c, { itemId, si, qty: 1, src: pl > 0 && oh >= 1 ? null : pl > 0 ? "preloved" : oh >= 1 ? "stock" : "order" }]; + }); + setIssueMsg(""); + } + function handleScan(raw: string) { + const p = bcParse(s, raw); + if (!p) { setScan(""); setBind(raw.trim()); return; } + addToCart(p.itemId, p.si); setScan(""); + } + function camHit(raw: string) { + const p = bcParse(s, raw); + if (!p) { setCam(false); setBind(raw.trim()); return; } + addToCart(p.itemId, p.si); + setCamMsg("Added " + label(byId[p.itemId]) + " · " + byId[p.itemId].sizes[p.si] + " — keep scanning or press Done"); + } + + const sq = staffQ.trim().toLowerCase(); + const matches = s.staff.filter((st) => !st.inactive).filter((st) => !sq || `${st.first} ${st.last}`.toLowerCase().includes(sq) || st.num.includes(sq)).slice(0, 6); + const cartQtyAll = cart.reduce((t, c) => t + c.qty, 0); + const nPl = cart.filter((c) => c.src === "preloved").reduce((t, c) => t + c.qty, 0); + // Pre-loved lines are free, so they stay out of what the ward is charged and out of what a + // manager's approval pays for. They are not out of the ceiling: six pre-loved tops fill a locker + // exactly as six new ones do, which is why the whole cart goes to capCheck() below. + const cartVal = cart.filter((c) => c.src !== "preloved").reduce((t, c) => t + c.qty * (byId[c.itemId]?.cost || 0), 0); + const nStock = cart.filter((c) => c.src === "stock").reduce((t, c) => t + c.qty, 0); + const nOrder = cart.filter((c) => c.src === "order").reduce((t, c) => t + c.qty, 0); + const anyUnpicked = cart.some((c) => c.src === null); + const used = sel ? entUsed(s, sel.id) : 0; + // The one question the counter asks, worked out by the same function the server refuses with: after + // this pickup, is this person still inside the six sets one person holds? Six at any time, every + // group, whichever route it takes — what somebody has on their back and in their locker, never a figure + // that starts again in July. This screen used to keep a private copy of the sum, and the day the + // copy and the server disagreed the coordinator was asked for a tick the record then contradicted. + // + // The whole cart goes in, ordered-in and pre-loved lines with the rest, because all three end up on + // the same person. Nothing here is a set count: the ceiling bites on tops and on trousers + // separately, or twenty tops and one pair would read as one set and pass. + const cap = useMemo(() => (sel ? capCheck(s, sel, cart.map((c) => ({ itemId: c.itemId, qty: c.qty }))) : null), [s, sel, cart]); + // The same question with an empty bag: is this person already past what one person holds? Only an + // override can have put them there, and the tag beside their name should say so rather than wait + // for somebody to put a garment in the cart. Asked of the rule rather than worked out here, because + // a locker of seven tops and two pairs is "two sets" by any count that isn't the rule's own. + const capHeld = useMemo(() => (sel ? capCheck(s, sel, []) : null), [s, sel]); + const selStanding = capHeld ? standing(capHeld, s.settings.capSets) : null; + // Sets held for every person on the register, in one walk of the issues — the search results below + // show it, and asking person by person makes a six-hundred-name register crawl. + const heldAll = useMemo(() => heldByStaff(s), [s]); + const capSets = setsCap(s.settings.capSets); + // Their allowance counted in SETS, from the one function that owns that rule, so the counter says + // what the wearer's own app says about the same person. + const allow = useMemo(() => { + if (!sel || !cap) return null; + // What they hold comes from capCheck, so this screen counts a person's uniform once. The + // facility's own figures go in with the question: left off, a site that issues four sets on + // starting goes on telling everybody three. Both route answers go in: without the starting-kit + // one, somebody whose group starts on a kit is told here that they start on nothing. + return allowance({ + group: sel.group, held: cap.sets, + nursing: isNursing(s, sel), kit: isKit(s, sel), + capSets: s.settings.capSets, startingSets: s.settings.initialSets, + }); + }, [s, sel, byId, cap]); + // allowance() words its sentence about nobody in particular, so the counter can say it about the + // person in front of it exactly as the wearer's own app says it to them. + const allowNote = allow ? allow.note : ""; + // Garments of the starting kit this record still owes. What they are owed on starting, and no part + // of what the counter refuses on: a new starter holds nothing and takes three sets, three is inside + // six, and the head-room this figure used to be added to the year's tally for existed only to stop + // somebody's own record turning their first collection into an override. + const kitLeft = sel ? initialRemaining(s, sel) ?? 0 : 0; + // Past what one person holds — one of the two things on this screen that asks for a tick. Nothing + // else blocks the button: stamping an ordinary collection as an override taught the linen room to + // tick the box without reading it, and that devalues every real one. + const overCap = !!sel && !!cap && cap.over; + // The other: garments in the bag that are not for this person's staff group. garmentForGroup() is + // the question the server refuses with, and the sentence is worded as its refusal is. The same tick + // lets either through; the server records a garment outside the group as that, never as the ceiling. + const offItems = useMemo(() => (sel ? [...new Set(cart.map((c) => c.itemId))].map((id) => byId[id]).filter((it): it is NonNullable => !!it && !garmentForGroup(it, sel.group)) : []), [sel, cart, byId]); + const offGroup = offItems.length > 0; + const selGroup = (sel?.group || "").trim(); + const offLine = offGroup && sel ? `${offItems.map((it) => `${it.item} is for ${groupsLabel(it.groups)}`).join("; ")} — ${staffName(sel)} ${selGroup ? `is in ${selGroup}` : "has no staff group recorded"}.` : ""; + // And the third: garments in the bag that are not the cut this person is offered. garmentForStyle() + // is the question the server refuses with, and the sentence is worded as its refusal is. Nothing is + // ever named here for somebody left blank or set to Either — both are offered every cut — so this + // line can only appear about a record a coordinator has set to Men's or Women's. + const offStyleItems = useMemo(() => (sel ? [...new Set(cart.map((c) => c.itemId))].map((id) => byId[id]).filter((it): it is NonNullable => !!it && !garmentForStyle(it, sel.uniformStyle)) : []), [sel, cart, byId]); + const offStyle = offStyleItems.length > 0; + const styleLine = offStyle && sel ? `${offStyleItems.map((it) => `${it.item} is the ${genderLabel(it.gender)} cut`).join("; ")} — ${staffName(sel)} is set to ${sel.uniformStyle}.` : ""; + const needsTick = overCap || offGroup || offStyle; + // The lead a coordinator checks against the person standing in front of them, worded the way the + // server words it when it refuses the same pickup: what they have out now, and then the reason, + // which comes from the rule itself rather than being worked out again here. + // + // What they hold includes what is on order for them or waiting to be collected, and nobody can see + // a garment on order in a locker — so, as the refusal does, the lead says how much of it is still + // to come, and only when some is. Somebody holding only garments outside a set is said to be + // holding those, not to have nothing out: "nothing out" to a person wearing the fleece they were + // issued is a sentence the coordinator can see is wrong. + const capLead = useMemo(() => { + if (!sel || !cap) return ""; + const inSets = cap.breach !== "other" && cap.tops + cap.pants > 0; + const holds = inSets ? `${count(cap.tops, "top", "tops")} and ${count(cap.pants, "pair", "pairs")}` : `${count(cap.other, "garment", "garments")} outside a set`; + const hasSome = inSets || cap.other > 0; + const coming = inSets ? cap.owed.tops + cap.owed.pants : cap.owed.other; + return `${staffName(sel)} ${hasSome ? `is holding ${holds}${coming ? `, ${coming} of them still to come` : ""}` : "has nothing out"}.`; + }, [sel, cap]); + const anyShort = cart.some((c) => (c.src === "stock" && c.qty > onhand(s, L, key(c.itemId, c.si))) || (c.src === "preloved" && c.qty > plOf(s, key(c.itemId, c.si)))); + const cannot = !sel || cart.length === 0 || anyShort || anyUnpicked || (needsTick && !override) || busy; + + // Manager's approval, whichever route they are on: oldest with sets remaining. + const ap = sel ? openApproval(s, sel.id) : undefined; + const apRem = sel ? approvalRemaining(s, sel.id) : 0; // across all open approvals (draws down oldest-first) + const cartTops = cart.reduce((t, c) => t + (isTopItem(byId[c.itemId]) && c.src !== "preloved" ? c.qty : 0), 0); + const cartPants = cart.reduce((t, c) => t + (isPantItem(byId[c.itemId]) && c.src !== "preloved" ? c.qty : 0), 0); + const apDefault = ap ? Math.min(apRem, Math.max(cartTops, cartPants)) : 0; + const apN = apDeduct === null ? apDefault : Math.min(apDeduct, apRem); + + // Repeat last issue: the person's most recent issue date, all non-returned lines that day. + const lastSet = useMemo(() => { + if (!sel) return []; + const past = s.issues.filter((i) => i.staffId === sel.id && !i.returned).sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)); + if (!past.length) return []; + return past.filter((i) => i.date === past[0].date && byId[i.itemId] && !byId[i.itemId].archived); + }, [s.issues, sel, byId]); + + // Quick add: garments for the person's group and cut (or everything when searching), usual size + // outlined. Both halves come from the shared helpers, so this list and the phone counter's agree. + const profSizes = sel ? [sel.top, sel.pants].filter(Boolean).map(String) : []; + const qaq = qaQ.trim().toLowerCase(); + const selBucket = sel ? groupBucket(sel.group) : ""; + const qaItems = useMemo(() => { + const out: { it: (typeof s.catalog)[number]; rel: boolean }[] = []; + for (const it of s.catalog) { + if (it.archived) continue; + if (qaq && !(it.item.toLowerCase().includes(qaq) || it.sku.toLowerCase().includes(qaq))) continue; + const rel = !sel || (inBucket(it, selBucket || "All groups") && garmentForStyle(it, sel.uniformStyle)); + if (!qaq && !rel) continue; + out.push({ it, rel }); + } + return out.sort((a, b) => (b.rel ? 1 : 0) - (a.rel ? 1 : 0)); + }, [s.catalog, qaq, sel, selBucket]); + const qaCap = qaq ? 14 : 10; + const qaNote = qaItems.length > qaCap ? `Showing ${qaCap} of ${qaItems.length} — type to narrow.` : sel && !qaq ? `Showing items for ${selBucket || "their group"}${sel && sel.uniformStyle && sel.uniformStyle !== UNIFORM_STYLE_EITHER ? `, ${sel.uniformStyle} cut` : ""} — type to search everything.` : ""; + + async function doIssue() { + if (cannot || !sel) return; + setBusy(true); + // Only the ticked box, and only while the box is on the screen. An override says somebody + // knowingly bent a rule, so nothing but a person may set it, and only about the bag they were + // shown: a tick left over from a pickup that has since come back inside six must not travel to + // the record as a decision nobody made about this one. + const r = await mutate<{ stock: number; ordered: number; preloved: number; apDeducted: number; apRemaining: number; offGroup?: number; offStyle?: number }>("issue.create", { staffId: sel.id, override: needsTick && override, apDeduct: ap ? apN : 0, lines: cart }); + setBusy(false); + if (!r.ok) { setIssueMsg(r.error); return; } + const parts = []; + if (r.result.stock) parts.push(`issued ${r.result.stock} from stock — replenishment draft updated on Ordering`); + if (r.result.ordered) parts.push(`ordered ${r.result.ordered} in (arrives to the pickup list)`); + // Free to the ward, and still uniform this person is holding — so it is never said here that a + // pre-loved garment doesn't count. It counts towards the six sets like anything else. + if (r.result.preloved) parts.push(`${r.result.preloved} pre-loved (free — nothing charged to the ward)`); + if (r.result.apDeducted) parts.push(`${r.result.apDeducted} set(s) off the manager's approval — ${r.result.apRemaining} remaining`); + if (r.result.offGroup) parts.push(`${count(r.result.offGroup, "garment", "garments")} outside their staff group, on the override`); + if (r.result.offStyle) parts.push(`${count(r.result.offStyle, "garment", "garments")} not their uniform style, on the override`); + setCart([]); setOverride(false); setApDeduct(null); + setIssueMsg(`Recorded for ${staffName(sel)}: ${parts.join(" · ")} (${money(cartVal)}). Print the receipt, get a signature, then tick “signed”.`); + } + // The slip is signed at the counter, so it has to say what actually crosses it. That is the shelf + // and pre-loved lines together: pre-loved is free, which is why it stays out of what is charged, but + // a free garment is still a garment the nurse walks away with and signs for. Ordered-in lines are + // not on this slip at all — they are not in the bag today, and they get their own collection slip + // off the pickup list when they arrive. + const handed = cart.filter((c) => c.src === "stock" || c.src === "preloved"); + const slipData = () => ({ + staffName: staffName(sel), dept: sel?.dept, sets: handed.reduce((t, c) => t + c.qty, 0), po: "", + // Itemised, so whoever signs can check the bag against the paper instead of trusting a total. + lines: handed.map((c) => `${c.qty} × ${longLabel(byId[c.itemId])} — ${byId[c.itemId]?.sizes[c.si] ?? "?"}${c.src === "preloved" ? " (pre-loved)" : ""}`).join("\n"), + dateReceived: s.today, requestedBy: sel?.num, deliveredBy: s.settings.coordinator, dateTime: s.today }); + const recent = useMemo(() => [...s.issues].filter((i) => !sel || i.staffId === sel.id).sort((a, b) => (b.createdAt > a.createdAt ? 1 : -1)).slice(0, 8), [s.issues, sel]); + + return ( +
+ +
+
+
+
+
1 · Staff member
+ {sel &&
{sel.num}
} +
+ {!sel ? ( + <> +
+ setStaffQ(e.target.value)} autoFocus /> +
+ {s.staff.length === 0 &&
No staff on the register yet — add them on the Staff Register screen.
} + {/* A real button, not a clickable row: this is step one of the counter's whole job, and + a div with an onClick puts it out of reach of the keyboard, of switch access and of + voice control. The styling is the row's, the semantics are the button's. */} +
+ {/* The same standing as the tag once they are picked, so a name that reads fine here + is not refused the moment somebody puts a top in the bag. */} + {matches.map((st) => { const h = heldAll[st.id] || { tops: 0, pants: 0, other: 0, sets: 0 }; const g = standing(h, s.settings.capSets); return ( + + ); })} +
+ + ) : ( +
+
+
{staffName(sel)}
+
+ Profile + + +
+
+
+
{sel.group}
+
{sel.dept} · Cost centre {ccOf(s, sel) || "—"}
+
Sizes: top {sel.top || "—"}, pants {sel.pants || "—"}
+
+ {/* What they are holding, in sets and in the two halves a set is made of, because the + ceiling bites on each half: somebody with six tops and two pairs is "two sets" and + still cannot be handed a seventh top. The tag says what the counter will do with the + next garment, not how many sets they have — AT LIMIT for six tops and two pairs, and + the sentence names the half that is full. Both are asked of lib/sets, so this screen, + the counter phone, the wearer's app and the server's own refusal cannot answer the + same question differently. */} + {capHeld && selStanding && ( +
+ {selStanding.tag} + Holds {capHeld.sets} of {capHeld.cap} sets — {count(capHeld.tops, "top", "tops")} and {count(capHeld.pants, "pair", "pairs")}{capHeld.other > 0 ? `, plus ${capHeld.other} outside a set` : ""}{capHeld.owed.tops + capHeld.owed.pants + capHeld.owed.other > 0 ? `, ${capHeld.owed.tops + capHeld.owed.pants + capHeld.owed.other} of them still to come` : ""}.{selStanding.over ? " Past what one person holds, so anything more needs a hand-in first or a coordinator override." : selStanding.room ? ` ${selStanding.room[0].toUpperCase()}${selStanding.room.slice(1)} without a hand-in first or a coordinator override.` : ""} +
+ )} + {allow && ( +
+
{allowNote}
+ {kitLeft > 0 &&
Starting kit: {kitLeft} of {initialGarments(s, sel) ?? kitLeft} garments still to issue.
} + {/* Kept where the linen room can see it, and labelled for what it is. It is the + figure the register and the monthly report quote; nothing on this screen and + nothing on the server turns anybody away on it. */} +
{used} garment{used === 1 ? "" : "s"} drawn since July — a running total for the reports, not a limit.
+
+ )} + {/* Sets a manager has already signed off are credit waiting to be spent, so the block + wears the same left rule as anything else on the screen that wants acting on. */} + {ap && ( +
+
Manager's approval: {ap.sets - ap.used} of {ap.sets} sets remaining{apRem > ap.sets - ap.used ? ` (+${apRem - (ap.sets - ap.used)} on later approvals)` : ""}
+
Approved {fmtDate(ap.date)} by {ap.by || "the manager"}
+ +
+ )} + {lastSet.length > 0 && ( + + )} +
+ )} +
+
+
{sel ? "Their issue history" : "Recent issues"}
+ {recent.length === 0 &&
{sel ? "Nothing issued yet." : "No issues recorded yet."}
} +
+ {recent.map((i) => { + const it = byId[i.itemId]; + return ( +
+
+
{label(it)} · {it?.sizes[i.si]} ×{i.qty}
+
{fmtDate(i.date)} · {staffName(staffById[i.staffId], "—")}{i.override ? " · override" : ""}{i.offGroup ? " · outside their group" : ""}{i.offStyle ? " · not their style" : ""}{i.direct ? " · pickup" : ""}
+
+ {i.returned ? Returned : i.handedIn ? Handed in : } + +
+ ); + })} +
+
+
+
+
+
+
2 · Scan items
+
or tap a size below
+
+
+ setScan(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && scan.trim()) handleScan(scan); }} /> + +
+
+
+ Quick add + setQaQ(e.target.value)} /> +
+ {qaItems.length === 0 && {s.catalog.length === 0 ? "The catalogue is empty — import it in Settings → Data." : "No items match."}} + {qaItems.slice(0, qaCap).map(({ it }) => ( +
+
{longLabel(it)}
+
+ {it.sizes.map((sz, si) => { + const inCart = cart.find((c) => c.itemId === it.id && c.si === si); + const usual = profSizes.includes(String(sz)); + return ; + })} +
+
+ ))} +
+
Tap a size to add it — tap again for +1. Outlined = their usual size, solid = in the cart; hover shows on-hand. {qaNote}
+
+
+
+
3 · The pickup
+ {cart.length > 0 &&
{cartQtyAll} item{cartQtyAll === 1 ? "" : "s"}
} +
+
+ {cart.map((c, i) => { + const it = byId[c.itemId]; const oh = onhand(s, L, key(c.itemId, c.si)); const pl = plOf(s, key(c.itemId, c.si)); + const setLine = (p: Partial) => setCart(cart.map((x, j) => j === i ? { ...x, ...p } : x)); + const note = c.src === "preloved" ? `${pl} pre-loved on hand` : c.src === "stock" ? `${oh} on hand` : c.src === null ? `${oh} on shelf · ${pl} pre-loved` : `order from ${it?.supplier || "supplier"} — lands in the pickup list when received`; + const mine = sel && it ? [sel.top, sel.pants].filter(Boolean).map(String).filter((x) => it.sizes.map(String).includes(x)) : []; + const sizeHint = it && mine.length && !mine.includes(String(it.sizes[c.si])) ? `Their usual size is ${mine.join(" / ")}` : ""; + const short = (c.src === "stock" && c.qty > oh) || (c.src === "preloved" && c.qty > pl); + return ( +
+
+
{label(it)}
+
Size {it?.sizes[c.si]} · {c.src === "preloved" ? "free" : `${money(it?.cost || 0)} each`} · {note}
+ {sizeHint &&
{sizeHint}
} + {/* A short line and an unpicked source both stop the issue being recorded, so each + one says so in words on the line it belongs to — the tag carries its own mark, + and the row carries the rule. */} + {c.src === "stock" && c.qty > oh &&
Not enough on the shelf — switch to order in
} + {c.src === "preloved" && c.qty > pl &&
Not enough in the pre-loved pool
} + {c.src === null &&
} +
+
+ {/* Three mutually exclusive choices, so the group is named once and each button + says whether it is the one in force — one
+ + + {c.qty} + + + +
+ ); + })} +
+ {cart.length === 0 &&
Scan a barcode or choose an item to start a pickup.
} + {/* The pickup that goes through, said in figures a coordinator can check against the pile on + the counter rather than left as a button that simply doesn't complain. It stands where the + red box used to: somebody collecting more than they expected — a new starter's kit, a + fourth set for somebody on the starting kit — is owed the reason it is allowed, and making them + sign that off as an override taught the linen room to tick the box without reading it. */} + {sel && cap && cart.length > 0 && !overCap && ( +
+
Inside what one person holds{needsTick ? "" : " — no override needed"}
+
{capLead} After this pickup: {cap.note}
+
+ )} + {/* One box and one tick for every reason, each reason said in its own words — as the + server's refusal names all of them at once, because one tick answers all of them at + once. The server records the ceiling, the staff group and the cut apart, so the report + can tell them apart too. */} + {needsTick && sel && cap && ( +
+ {overCap && ( + <> +
+
{capLead} {cap.note}
+ + )} + {offGroup && ( + <> +
+
{offLine}
+ + )} + {offStyle && ( + <> +
+
{styleLine}
+ + )} + +
+ )} + {ap && cart.length > 0 && ( +
+ Deduct from the manager's approval: + + {apN} + + sets ({apRem} remaining) +
+ )} + {/* What the bag is worth, at the bottom of the bag. This is the figure the coordinator + reads back before anyone signs, so it is the screen's figure, not a line of small + print in a toolbar. */} +
+
+
{cartQtyAll} item{cartQtyAll === 1 ? "" : "s"} · to charge
+
{money(cartVal)}
+
{cart.length ? `${nStock} from stock${nPl > 0 ? ` · ${nPl} pre-loved (free)` : ""}${nOrder > 0 ? ` · ${nOrder} ordered in` : ""}` : ""}
+
+
+ + + +
+
+
+ +
+
+ {cam && setCam(false)} />} + {bind && setBind("")} onBound={(itemId, si) => addToCart(itemId, si)} />} + {ret && setRet(null)} />} + {handin && sel && setHandin(false)} onDone={(msg) => setIssueMsg(msg)} />} +
+ ); +} diff --git a/app/app/layout.tsx b/app/app/layout.tsx new file mode 100644 index 0000000..5536544 --- /dev/null +++ b/app/app/layout.tsx @@ -0,0 +1,20 @@ +import { redirect } from "next/navigation"; +import { currentUser } from "@/lib/session"; +import { buildSnapshot } from "@/lib/snapshot"; +import { SnapshotProvider } from "@/lib/client"; +import Shell from "@/components/Shell"; +import Analytics from "@/components/Analytics"; + +export const dynamic = "force-dynamic"; + +export default async function AppLayout({ children }: { children: React.ReactNode }) { + const user = await currentUser(); + if (!user) redirect("/auth"); + const snap = await buildSnapshot(user); + return ( + + {children} + + + ); +} diff --git a/app/app/orders/[id]/page.tsx b/app/app/orders/[id]/page.tsx new file mode 100644 index 0000000..e5eacaf --- /dev/null +++ b/app/app/orders/[id]/page.tsx @@ -0,0 +1,374 @@ +"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, KpiStrip, LiveRegion } from "@/components/ui"; +import { ReceiveDialog } from "@/components/dialogs"; +import { viewPhoto } from "@/lib/photo"; +import { 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>({}); + const [draft, setDraft] = useState>({}); + // 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>({}); + const qtyWanted = useRef>({}); + // 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>({}); + 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>({}); + const timers = useRef>>({}); + /* 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; + }); + }); + + if (!o) return
← All orders
; + + const st = o.staffId ? staffById[o.staffId] : undefined; + const overdue = isOverdue(o, s.today); + const forLabel = o.orderFor === "Stock" ? "For stock" : "For " + staffName(st, "staff member"); + const ccCode = ccOfOrder(s, o, staffById); + const ccNote = ccBudgetNote(s, byId, staffById, ccCode, " (incl. this one)"); + + function saveField(k: string, v: string) { + setDraft((d) => ({ ...d, [k]: v })); + fieldWanted.current[k] = v; + clearTimeout(timers.current[k]); + timers.current[k] = setTimeout(async () => { + // Off the pending list the moment it is on its way: a keystroke that lands after this point + // has already put its own value back and scheduled its own timer. + if (fieldWanted.current[k] === v) delete fieldWanted.current[k]; + const r = await mutate("order.update", { id: o!.id, [k]: v }); + if (!r.ok) setErr(r.error); + }, 400); + } + const val = (k: keyof typeof o) => (draft[k] !== undefined ? draft[k] : String(o[k] ?? "")); + /* The order as the coordinator can actually see it: the taps and the typing still sitting in a + * debounce, laid over the snapshot that has not caught up with them yet. + * + * Everything that puts this order in front of a person reads it — the lines, the total, the + * printed purchase order, the CSV, the receive dialog — so what leaves the building says what the + * screen said when it was asked for. Printing from the snapshot sent the supplier a tunic count + * one tap behind. Sending the pending write first would not have fixed it: the refreshed snapshot + * lands some time after the write returns, and the print window has to open on the click itself + * or the browser blocks it. Actions the server answers out of its own copy go through flushQty() + * instead — that is what the database has to be right about. */ + const onScreen = { ...o, ref: val("ref"), invoice: val("invoice"), tracking: val("tracking"), expected: val("expected"), supplier: val("supplier"), notes: val("notes"), lines: o.lines.map((l) => (qtyDraft[l.id] !== undefined ? { ...l, qty: qtyDraft[l.id] } : l)) }; + async function act(op: string, payload: unknown) { setErr(""); const r = await mutate(op, payload); if (!r.ok) setErr(r.error); return r.ok; } + /* Steppers count from what has been tapped, never from the snapshot. + * + * order.lineQty takes an absolute quantity and the snapshot only catches up once a write comes + * back, so reading l.qty on every tap meant six quick taps on the size-14 tunic all posted + * qty: 2: the line settled at 2 or 3 and the purchase order went to the supplier four tunics + * short. Each tap now adds to the pending figure and the debounce sends whatever it reached. + * + * `shown` is the number on the screen, which is the one the coordinator is counting from. It + * matters in the gap between a write landing and the refreshed snapshot arriving: the pending + * figure is cleared the moment the write returns, so a tap in that gap would otherwise fall back + * to the snapshot and count from the old quantity again — the very defect this exists to stop. + * The pending figure still wins where it exists, because two taps in one frame both read the same + * already-rendered number. */ + function bumpQty(lineId: string, shown: number, by: number) { + clearTimeout(timers.current["qtyclear:" + lineId]); + const next = (qtyWanted.current[lineId] ?? shown) + by; + qtyWanted.current[lineId] = next; + setQtyDraft((d) => ({ ...d, [lineId]: next })); + clearTimeout(timers.current["qty:" + lineId]); + timers.current["qty:" + lineId] = setTimeout(() => { void sendQty(lineId); }, 300); + } + async function sendQty(lineId: string) { + const want = qtyWanted.current[lineId]; + if (want === undefined) return true; + clearTimeout(timers.current["qty:" + lineId]); + const ok = await act("order.lineQty", { id: o!.id, lineId, qty: want }); + // A tap that landed while this write was in the air has already raised the target; leaving it + // pending lets the timer that tap scheduled send the higher number instead of losing it here. + if (qtyWanted.current[lineId] === want) { + delete qtyWanted.current[lineId]; + // Nothing was saved, so the tapped number must come off the screen now rather than sit there + // above the error looking like a quantity the supplier is going to be sent. + if (!ok) dropPendingQty(lineId); + else { + qtySeen.current[lineId] = snapLines.current?.find((l) => l.id === lineId)?.qty ?? want; + timers.current["qtyclear:" + lineId] = setTimeout(() => dropOverriddenQty(lineId, want), 2000); + } + } + return ok; + } + /* The backstop for the case where the snapshot never comes to agree — someone else editing the + * same draft line. Without it this screen would keep showing our number over theirs. + * + * It runs on a clock, so it must never act on a snapshot that is merely late. Dropping the draft + * the moment the two seconds were up put the pre-tap quantity back on the screen whenever the + * refreshed snapshot was slower than that, and the next tap counted on from it — the miscount all + * of this exists to stop. A snapshot still showing the figure it had when our write came back, + * and not the figure we wrote, has not caught up yet: the tapped number stays and this waits + * another two seconds. Once it moves — to ours, or to whatever the other coordinator saved — the + * draft has nothing left to protect and goes. */ + function dropOverriddenQty(lineId: string, wrote: number) { + const line = snapLines.current?.find((l) => l.id === lineId); + if (line && line.qty !== wrote && line.qty === qtySeen.current[lineId]) { timers.current["qtyclear:" + lineId] = setTimeout(() => dropOverriddenQty(lineId, wrote), 2000); return; } + dropPendingQty(lineId); + } + // Send anything still sitting in the debounce before an action the server answers out of its own + // copy of the order — it reads the lines the database holds, not the ones on this screen — or + // before one that closes the draft to edits and would have the pending write refused. + async function flushQty() { + for (const lineId of Object.keys(qtyWanted.current)) if (!(await sendQty(lineId))) return false; + return true; + } + function dropPendingQty(lineId: string) { + clearTimeout(timers.current["qty:" + lineId]); + clearTimeout(timers.current["qtyclear:" + lineId]); + delete qtyWanted.current[lineId]; + delete qtySeen.current[lineId]; + setQtyDraft((d) => { const n = { ...d }; delete n[lineId]; return n; }); + } + async function removeLine(lineId: string) { dropPendingQty(lineId); await act("order.lineRemove", { id: o!.id, lineId }); } + /* Every line is priced the way orderTotal() prices it — delivered units at the cost the delivery + * was invoiced at, whatever is still outstanding at today's catalogue price — so the rows a + * coordinator ticks off against the invoice add up to the total printed under them. Pricing the + * rows from the catalogue while the total came from orderTotal() left the two visibly disagreeing + * as soon as a delivery arrived at a different price, on the one screen where that sum is checked. + * + * The amounts come out of orderTotal() itself rather than a second copy of its arithmetic: what + * line n contributes is the total of the first n lines less the total of the first n−1. Asking it + * about a line on its own would not do, because it draws each delivery down across the lines in + * order — two lines for the same size would then both claim the same delivery. */ + const lineAmt: Record = {}; + 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 `${esc(label(it))}${esc(it?.sku || "—")}${esc(l.size)}${l.qty}${esc(money(unitOf(l)))}${esc(money(lineAmt[l.id]))}`; }).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 = `

Purchase order — ${esc(onScreen.code)}

${esc(s.settings.facility)} · ${esc(s.settings.location)}
` + + `
Supplier: ${esc(onScreen.supplier)}${sp && (sp.contact || sp.phone) ? " · " + esc([sp.contact, sp.phone].filter(Boolean).join(" · ")) : ""}
Date: ${esc(fmtDate(onScreen.date || s.today))}
Supplier ref: ${esc(onScreen.ref || "—")}
Expected: ${esc(onScreen.expected ? fmtDate(onScreen.expected) : "—")}
Account: ${esc(sp?.account || "—")} · ${esc(forLabel)}
Cost centre: ${esc(ccCode || "—")}
` + + `${rows}
ItemSKUSizeQtyUnitTotal
Total ${esc(money(orderTotal(onScreen, byId)))}
` + + (onScreen.notes ? `
Notes: ${esc(onScreen.notes)}
` : "") + `
Ordered by ____________________    Date ____________
`; + openPrintWindow(onScreen.code, body, { page: "size:A4;margin:16mm", css, width: 780, height: 920 }); + } + function exportCsv() { + downloadCsv(onScreen.code.toLowerCase() + ".csv", `Order,${csvEsc(onScreen.code)}\nSupplier,${csvEsc(onScreen.supplier)}\nRef,${csvEsc(onScreen.ref)}\n\n` + csvOf(["Item", "SKU", "Size", "Qty", "Unit cost", "Total"], onScreen.lines.map((l) => { const it = byId[l.itemId]; return [label(it), it?.sku || "", l.size, l.qty, +unitOf(l).toFixed(2), lineAmt[l.id].toFixed(2)]; }))); + } + 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 ( +
+
+
+ {/* Flush with the eyebrow under it: the button's own 14px of padding would otherwise + indent the one thing on the band that has to line up with the order number. */} + ← All orders +
Supply · Order
+

{o.code}

+
{forLabel} · {onScreen.supplier} · placed {fmtDate(o.date)}{o.replenish ? " · replenishment" : ""}{parent && <> · back order of {parent.code}}
+
+
+ {overdue && Overdue} + {o.status} + + + + {isAdmin && ["Draft", "Ordered", "Back Order", "Shipped"].includes(o.status) && } + {o.status === "Draft" && } + {["Ordered", "Back Order"].includes(o.status) && } + {["Ordered", "Shipped", "Back Order"].includes(o.status) && } +
+
+ {/* The left rule is what says "something is wrong here" from across the room. The red on its + own would be the same red the status tag beside it wears when an order is merely open. */} + + {/* A late delivery is the one thing on this order somebody has to act on, so the date is + marked the way the rest of the app marks trouble rather than simply turning red — the + status tag two inches above it is already red on every order that is merely open. */} + 0 && got >= units ? "Everything ordered has arrived" : o.status === "Draft" ? "Not sent to the supplier yet" : "Receive a delivery to book the rest in" }, + { val: onScreen.expected ? fmtDate(onScreen.expected) : "—", label: "Expected", flag: overdue, note: overdue ? `${daysBetween(onScreen.expected, s.today)} day${daysBetween(onScreen.expected, s.today) === 1 ? "" : "s"} overdue — ring ${onScreen.supplier}` : onScreen.expected ? "The date the supplier gave" : "No delivery date recorded" }, + ]} /> +
+
+
+
+ Order details + Changes save as you type +
+
+
+ {([["ref", "Supplier order no.", "text", "e.g. WWG-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]) => ( + {(c) => saveField(k, e.target.value)} />} + ))} + + {(c) => s.settings.suppliers.length ? : saveField("supplier", e.target.value)} />} + + + {(c) => ( + + )} + + + {(c) => ( + + )} + + {ccNote &&
{ccNote}
} + {(c) => saveField("notes", e.target.value)} />} +
+
+
+
+
+ Lines + {units} unit{units === 1 ? "" : "s"} ordered +
+
+ {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 ( +
+
+
{label(it)}
+
size {l.size}{rec ? ` · received ${rec}` : ""}{Math.abs(unit - catCost) > 0.004 && · invoice price}
+
+
+ {o.status === "Draft" ? ( + + + ×{l.qty} + + + ) : ×{l.qty}} + @ $ + 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 && } +
+
{money(l.qty * unit)}
+
+ ); + })} +
+ {o.status === "Draft" && ( +
+ Add line: + { if (await flushQty()) act("order.lineAdd", { id: o.id, itemId: it.id, size: it.sizes[si], qty: 1 }); }} /> +
+ )} +
+
{isAdmin ? "Editing a price updates that item’s catalogue cost everywhere." : "Prices are set by an admin."}
+
{money(total)}
+
+
+ {backOrders.length > 0 &&
Back order{backOrders.length > 1 ? "s" : ""}: {backOrders.map((b) => {b.code})}
} +
+
+
+
History
+
+ {ev.map((e, i) => ( +
+
{e.date ? fmtDate(e.date) : "—"}
+
+
{e.what}{e.photoId && }
+ {e.sub &&
{e.sub}
} +
+
+ ))} +
+
+ {st && ( +
+
Staff member
+
+
{staffName(st)} {st.num}
+
{st.dept} · {st.phone || "no phone"}
+
+
+ )} + {isAdmin && o.status === "Draft" && o.replenish && ( +
This draft grows as stock is issued — mark it ordered when you send it to {onScreen.supplier}.
+ )} +
+
+ {rcv && { setRcv(false); router.refresh(); }} />} +
+ ); +} diff --git a/app/app/orders/page.tsx b/app/app/orders/page.tsx new file mode 100644 index 0000000..28a5c8c --- /dev/null +++ b/app/app/orders/page.tsx @@ -0,0 +1,177 @@ +"use client"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { useDerived, useSnap } from "@/lib/client"; +import { PageHead, Empty, KpiStrip, Seg } from "@/components/ui"; +import { NewOrderDialog } from "@/components/dialogs"; +import { ccOfOrder, csvOf, daysBetween, fmtDate, isOpen, isOverdue, isPlacedOpen, label, money, onhand, orderTotal, reorderAt, staffName, statusTag, touched } from "@/lib/compute"; +import { downloadCsv } from "@/lib/print"; + +const STATUSES = ["All", "Draft", "Open", "Received"] as const; + +export default function OrdersPage() { + const { s, mutate } = useSnap(); + const [flagMsg, setFlagMsg] = useState(""); + const { L, byId, staffById, variants } = useDerived(); + const [dlg, setDlg] = useState(null); + const [q, setQ] = useState(""); + const [sup, setSup] = useState("All suppliers"); + const [status, setStatus] = useState<(typeof STATUSES)[number]>("All"); + + const suggested = useMemo(() => { + const out: { itemId: string; size: string; lbl: string; oh: number; ro: number; sugg: number }[] = []; + for (const v of variants) { + const oh = onhand(s, L, v.key), ro = reorderAt(s, v.key); + if (!touched(s, L, v.key) || oh > ro) continue; + const onOrder = s.orders.some((o) => isOpen(o) && o.lines.some((l) => l.itemId === v.itemId && l.size === v.size)); + if (!onOrder) out.push({ itemId: v.itemId, size: v.size, lbl: label(v.item), oh, ro, sugg: Math.max(ro * 2 - oh, 1) }); + } + return out; + }, [s, L, variants]); + + const kpi = useMemo(() => { + const drafts = s.orders.filter((o) => o.status === "Draft").length; + const open = s.orders.filter(isPlacedOpen); + const overdue = s.orders.filter((o) => isOverdue(o, s.today)).length; + const monthVal = s.orders.filter((o) => o.status === "Received" && o.received.slice(0, 7) === s.today.slice(0, 7)).reduce((t, o) => t + orderTotal(o, byId), 0); + return { drafts, open: open.length, openVal: open.reduce((t, o) => t + orderTotal(o, byId), 0), overdue, monthVal }; + }, [s, byId]); + + const supOpts = ["All suppliers", ...new Set(s.orders.map((o) => o.supplier).filter(Boolean))]; + 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 !== "All suppliers" && 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 (ql) { const st = o.staffId ? staffById[o.staffId] : undefined; const hay = `${o.code} ${o.ref} ${o.invoice} ${o.tracking} ${o.supplier} ${staffName(st)}`.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 !== "All suppliers" || status !== "All"; + /* This list is the view somebody reads to answer "what is still outstanding?" or "what did we + order this quarter?", and the only way to get any of it out of ThreadCount was to open one + order at a time and export each. The file is the rows on screen — the search, the supplier and + the status tab all apply — because a coordinator who has narrowed to one supplier means that + supplier, not three years of ordering. + + Value is orderTotal(), never quantity times catalogue price. orderTotal prices what has already + been delivered at the cost the delivery was invoiced at and only the rest at today's catalogue + price; multiplying it out here would mean the finance spreadsheet disagreed with the order + screen, Reports and the dashboard the moment an admin edited a price — the exact fault that was + just fixed everywhere else. + + Dates go out in the stored form (2026-09-11), not as the screen prints them, so a spreadsheet + sorts and filters them as dates. Order notes are left out: they carry remarks for the linen + room — a supplier dispute, a substitution offered — they are not on this screen, and the screen + is the limit of what Export hands over. */ + function exportCsv() { + // Ordered for and Staff member are split apart because the screen's single line ("For stock", + // "For Jane Doe") can't be filtered on in a spreadsheet. Units ordered against Units received is + // what makes a part-delivered order visible in the file, the way the Overdue tag makes a late + // one visible here. + 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 ( +
+ + + + + {/* Every open order on this screen already wears the brand red on its status tag, so a red + figure on its own would say nothing here. Overdue earns the rule and the mark instead, and + only when something is actually late. */} + 0, note: kpi.overdue > 0 ? "Past the date the supplier gave" : "Everything open is still within its expected date" }, + { val: money(kpi.monthVal), label: "Received this month", note: "Delivered stock, priced as invoiced" }, + ]} /> +
+ setQ(e.target.value)} /> + + +
+ {suggested.length > 0 && ( + /* Stock at or below its reorder level is the one thing on this screen that has to be acted + on today, so it is marked the way everything else that wants attention is marked — the + rule down the edge and the mark beside the word — rather than by being the only red box + on a screen that already has red status tags on it. */ +
+
+ + {suggested.length} line{suggested.length === 1 ? "" : "s"} at or below reorder level +
+
+ {suggested.slice(0, 10).map((x, i) => ( +
+
+
{x.lbl} · {x.size}
+
on hand {x.oh} · reorder at {x.ro}
+
+
+{x.sugg}
+
+ ))} +
+
+
+ {suggested.length > 10 && <>+{suggested.length - 10} more lines. } + Adds each line to its supplier's replenishment draft, topping up to 2× the reorder level and netting off stock already on order.{flagMsg && {flagMsg}} +
+ +
+
+ )} +
+
+ Orders + {orders.length} of {s.orders.length} orders{orders.length > 0 ? " · Export CSV writes these" : ""} +
+ {orders.length === 0 &&
{s.orders.length === 0 ? "No orders yet." : "No orders match this filter."}
} +
+ {orders.map((o) => { + const st = o.staffId ? staffById[o.staffId] : undefined; + const overdue = isOverdue(o, s.today); + /* "1 days overdue" is the kind of thing that makes a screen look unfinished, and a date + printed for today or yesterday makes you do arithmetic to work out what it means. The + two nearest days get named instead; anything further out is a count of days, plural + only when it is one. */ + 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 ${fmtDate(o.expected)}` + : ""; + return ( + +
+
{o.code}
+
+ {o.orderFor === "Stock" ? "For stock" : "For " + staffName(st, "staff member")} · {o.supplier} · {fmtDate(o.date)}{o.ref ? " · ref " + o.ref : ""} + {/* No mark here: the Overdue tag across the row already carries one, and the + same signal twice on one line reads as two different problems. */} + {due && <> · {due}} +
+
+ {o.replenish && Replenishment} + {overdue && Overdue} + {o.status} +
{money(orderTotal(o, byId))}
+ + + ); + })} +
+
+ {dlg === "new" && setDlg(null)} />} +
+ ); +} diff --git a/app/app/page.tsx b/app/app/page.tsx new file mode 100644 index 0000000..6ef2074 --- /dev/null +++ b/app/app/page.tsx @@ -0,0 +1,178 @@ +"use client"; +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; +import { useDerived, useSnap } from "@/lib/client"; +import { PageHead, Empty, ErrorLine, KpiStrip } from "@/components/ui"; +import { NewOrderDialog, openSlip } from "@/components/dialogs"; +import { capState, daysBetween, fyStart, heldByStaff, isOpen, isOverdue, label, money, onhand, orderTotal, reorderAt, setsCap, staffName, touched, telHref, type GarmentCounts } from "@/lib/compute"; + +/** Somebody with nothing out and nothing owed. heldByStaff only lists people holding something, and + * they have to be read as holding none rather than skipped. */ +const NOTHING: GarmentCounts = { tops: 0, pants: 0, other: 0, sets: 0 }; + +export default function Dashboard() { + const { s, mutate } = useSnap(); + const { L, byId, staffById, variants } = useDerived(); + const [newOrder, setNewOrder] = useState(false); + const [welcome, setWelcome] = useState(false); + const [err, setErr] = useState(""); + useEffect(() => { if (new URLSearchParams(window.location.search).get("welcome") === "1") setWelcome(true); }, []); + + // The call list is the one place two people work the same rows at once — one marks a bag + // collected at the counter while somebody else is still on the phone about it. A refusal there + // has to be said out loud, or the second click reads as the first one not having taken. + async function act(op: string, payload: unknown) { setErr(""); const r = await mutate(op, payload); if (!r.ok) setErr(r.error); } + + const d = useMemo(() => { + const openOrders = s.orders.filter(isOpen); + const overdue = s.orders.filter((o) => isOverdue(o, s.today)); + const pickupQ = s.pickups.filter((p) => !p.pickedUp); + const wait14 = pickupQ.filter((p) => daysBetween(p.received, s.today) >= 14); + // Spend = orders actually placed this month (drafts, incl. auto-replenishment, are not spend yet). + const mtd = s.orders.filter((o) => o.date.slice(0, 7) === s.today.slice(0, 7) && o.status !== "Cancelled" && o.status !== "Draft" && !o.parentId).reduce((t, o) => t + orderTotal(o, byId), 0); + const lowRows: { label: string; size: string; onhand: number; reorder: number }[] = []; + for (const v of variants) { const oh = onhand(s, L, v.key), ro = reorderAt(s, v.key); if (touched(s, L, v.key) && oh <= ro) lowRows.push({ label: label(v.item), size: v.size, onhand: oh, reorder: ro }); } + // Who holds more than one person holds: what is out with them and what is owed to them, against + // six sets at any time. Asked exactly the way the staff register asks it, so the two screens + // always give the same count. It is not what anybody drew this year. By that measure a new + // starter handed three sets on Monday has had a year's worth and is nowhere near the ceiling, and + // a tile calling her over sends a coordinator after somebody the counter would serve without a + // second look. + // + // There is no "nearly there" count beside it. Holding the full six is where somebody fully kitted + // is meant to be, not a warning, so on a settled ward it would be most of the ward. + const held = heldByStaff(s); + const over = s.staff.filter((st) => !st.inactive && capState({ held: held[st.id] || NOTHING, capSets: s.settings.capSets }).over).length; + const cap = setsCap(s.settings.capSets); + const fy = fyStart(s.today); + return { openOrders, overdue, pickupQ, wait14, mtd, lowRows, over, cap, fy }; + }, [s, L, byId, variants]); + + /* The tiles are the read from the doorway: four things somebody has to act on today. Each one is + either quiet or flagged, and a flagged tile says so three ways — a rule down its edge, a mark + against the figure, and a note in plain words — because the vermilion is already the brand + colour on the rail and on every primary button, and a second red across the room is a guess + rather than a signal. + + Nothing on a tile is repeated in the registers below it. A figure printed twice on one screen is + two chances to disagree, and the linen room reads whichever one it happened to land on. */ + const tiles = [ + { label: "Awaiting pickup", val: d.pickupQ.length, note: d.wait14.length ? `${d.wait14.length} waiting a fortnight or more` : "bags received and not collected", flag: d.wait14.length > 0 }, + { label: "Overdue for delivery", val: d.overdue.length, note: d.overdue.length ? "past the date the supplier gave" : "nothing past its delivery date", flag: d.overdue.length > 0 }, + { label: "Lines at reorder", val: d.lowRows.length, note: d.lowRows.length ? "at or below their reorder level" : "every line above its reorder level", flag: d.lowRows.length > 0 }, + { label: "Over the ceiling", val: d.over, note: d.over ? `past the ${d.cap} sets one person holds` : `nobody past the ${d.cap} sets one person holds`, flag: d.over > 0 }, + ]; + + // The facts that are not a task: true of the facility, worth a glance, never the reason somebody + // walks to the counter. Three registers, one per part of the job. + const registers: { title: string; rows: [string, React.ReactNode][] }[] = [ + { title: "Orders", rows: [["Open orders", d.openOrders.length], ["Month-to-date spend", money(d.mtd)]] }, + { title: "Stock", rows: [ + ["Pre-loved pool", (() => { let u = 0, sz = 0; for (const k in s.stock) if (s.stock[k].preloved > 0) { u += s.stock[k].preloved; sz++; } return u ? `${u} across ${sz} size${sz === 1 ? "" : "s"}` : "0"; })()], + ["Issues recorded (FY)", s.issues.filter((i) => i.date >= d.fy).length], + ["Stocktake adjustments", Object.values(s.stock).filter((x) => x.adj).length], + ] }, + // "Receipts not yet signed" reads handedIn as well as returned: a garment handed back at the + // counter is stamped handedIn and never gets a returned date, so it can never be signed for. + // Counting those made the figure a queue that only ever grew, which is how a number the linen + // room is meant to work down stops being read at all. + { title: "Staff", rows: [["On the register", s.staff.filter((st) => !st.inactive).length], ["Receipts not yet signed", s.issues.filter((i) => !i.receipt && !i.returned && !i.handedIn).length]] }, + ]; + + const tasks = d.pickupQ.map((p) => ({ p, st: staffById[p.staffId], days: daysBetween(p.received, s.today) })).sort((a, b) => b.days - a.days); + const setupNeeded = s.catalog.length === 0 || s.staff.length === 0; + + return ( +
+ + Issue stock + + + {(welcome || setupNeeded) && ( +
+
+
+
{welcome ? "Welcome to ThreadCount" : "Finish setting up"}
+
+ {s.catalog.length === 0 ? "Your catalogue is empty — import it from a CSV in Settings → Data. " : ""} + {s.staff.length === 0 ? "The staff register is empty — add staff or import a CSV. " : ""} + {!setupNeeded && "Your facility is set up. Set reorder levels on the Inventory screen and start issuing."} +
+
+ Open Settings → Data + {welcome && } +
+
+ )} + +
+
+
+
Awaiting pickup — call list
+
sorted by days waiting
+
+ {/* ErrorLine draws nothing when there is nothing to say, so this wrapper collapses with it + rather than opening a gap above the first row. */} +
+ {tasks.length === 0 &&
Nothing waiting to be collected.
} +
+ {tasks.map(({ p, st, days }) => { + const ord = s.orders.find((o) => o.id === p.orderId); + const late = days >= 14; + return ( +
+
{days}d
+
+
{staffName(st, "Staff")} {telHref(st?.phone) ? {st?.phone} : {st?.phone}}
+
{late &&
+
+ {p.contacted ? Contacted : } + + +
+ ); + })} +
+
+
+ {/* Every row in here is by definition at its reorder level, so a rule down all of them + would mark nothing. The rule is kept for the sizes that are actually empty — a nurse + at the counter can be handed a low size and cannot be handed none. */} +
+
Reorder flags
+ {d.lowRows.length > 0 &&
{d.lowRows.length} line{d.lowRows.length === 1 ? "" : "s"}
} +
+ {d.lowRows.length === 0 &&
No stock lines at or below reorder level.
} +
+ {d.lowRows.slice(0, 12).map((r, i) => ( +
+
+
{r.label} · {r.size}
+
{r.onhand <= 0 &&
+
+
{r.onhand}
+
+ ))} +
+ {d.lowRows.length > 12 &&
+{d.lowRows.length - 12} more — use Order flagged on Inventory.
} +
+
+
+ {registers.map((g) => ( +
+
{g.title}
+
+ {g.rows.map(([lbl, val]) => ( +
+
{lbl}
+
{val}
+
+ ))} +
+
+ ))} +
+ {newOrder && setNewOrder(false)} />} +
+ ); +} diff --git a/app/app/report/page.tsx b/app/app/report/page.tsx new file mode 100644 index 0000000..f303ae1 --- /dev/null +++ b/app/app/report/page.tsx @@ -0,0 +1,537 @@ +"use client"; +import { useMemo, useState } from "react"; +import { useDerived, useSnap } from "@/lib/client"; +import { PageHead, Dialog, Empty, KpiStrip, th } from "@/components/ui"; +import { ccOf, countsAsIssued, csvEsc, csvOf, fmtDate, fyStart, issueCost, label, longLabel, money, monthLabel, onhand, orderTotal, prevMonth, setsCap, shiftMonth, signedInt, signedMoney, staffName } from "@/lib/compute"; +import { downloadCsv, printDoc, tbl, type Col } from "@/lib/print"; + +const TABS = ["Overview", "Journal", "Top stock", "Valuation", "Shrinkage", "Exceptions", "Suppliers", "Approvals", "Pre-loved"] as const; +type Tab = (typeof TABS)[number]; + +/* Every table and every list on this screen sits in the same bordered block with its name across + the top, because nine tabs that each invent their own heading is how a month-end pack ends up + looking like nine different reports. `flag` is for the tab that is telling finance something is + wrong — an unpostable journal line, stock gone missing, a garment handed over past the ceiling — + and marks it the way the rest of the app marks trouble: a rule down the edge and a mark beside + the name, so it does not rely on a red that is also the brand's. */ +function Panel({ title, aside, right, flag, children }: { title: React.ReactNode; aside?: React.ReactNode; right?: React.ReactNode; flag?: boolean; children: React.ReactNode }) { + return ( +
+
+ {flag && + {(aside || right) && {aside && {aside}}{right}} +
+ {children} +
+ ); +} + +export default function ReportPage() { + const { s } = useSnap(); + const { L, byId, staffById } = useDerived(); + const [month, setMonth] = useState(s.today.slice(0, 7)); + const [tab, setTab] = useState("Overview"); + // The cost centre whose issues are open, as the keys its figure was summed from. Only the keys + // are held: the items and the money are recounted from the snapshot on every render, so the + // drill-down still agrees with the row underneath it if stock moves while the dialog is open. + const [drill, setDrill] = useState<{ cc: string; dept: string; keys: string[] } | null>(null); + + const R = useMemo(() => { + const months = new Set([s.today.slice(0, 7)]); + s.issues.forEach((i) => months.add(i.date.slice(0, 7))); s.orders.forEach((o) => o.date && months.add(o.date.slice(0, 7))); + for (let i = 5; i >= 0; i--) months.add(shiftMonth(month, -i)); // trend bars are clickable, so they must be selectable + const repMonths = [...months].sort().reverse(); + const cost = (itemId: string) => byId[itemId]?.cost || 0; // catalogue cost (stocktake lines, valuation) + const mIssues = s.issues.filter((i) => i.date.slice(0, 7) === month && countsAsIssued(i) && !i.preloved); + const mPl = s.issues.filter((i) => i.date.slice(0, 7) === month && countsAsIssued(i) && i.preloved); + const pm = prevMonth(month); + const pIssues = s.issues.filter((i) => i.date.slice(0, 7) === pm && countsAsIssued(i) && !i.preloved); + // Pre-loved: free reissues (value saved at catalogue cost), hand-ins, and the pool at $0. + const plIssueRows = mPl.map((i) => { const it = byId[i.itemId]; return { date: i.date, who: staffName(staffById[i.staffId], "—"), item: label(it), size: it ? String(it.sizes[i.si]) : "?", qty: i.qty, saved: i.qty * (it?.cost || 0) }; }); + const plSaved = plIssueRows.reduce((t, r) => t + r.saved, 0), plQty = mPl.reduce((t, i) => t + i.qty, 0); + const mHi = s.handins.filter((h) => h.date.slice(0, 7) === month); + const hiRows = mHi.map((h) => ({ date: h.date, who: staffName(staffById[h.staffId], "—"), by: h.by, good: h.lines.filter((l) => l.cond === "Good").reduce((t, l) => t + l.qty, 0), rag: h.lines.filter((l) => l.cond === "Rag").reduce((t, l) => t + l.qty, 0), credit: h.credit ? "Credited" : "—" })); + const ragMonth = hiRows.reduce((t, r) => t + r.rag, 0); + const plByItem: Record = {}; + for (const k in s.stock) { const n = s.stock[k].preloved; if (!(n > 0)) continue; const itemId = k.slice(0, k.lastIndexOf(":")), si = +k.slice(k.lastIndexOf(":") + 1); const it = byId[itemId]; if (!it) continue; (plByItem[itemId] = plByItem[itemId] || []).push(`${it.sizes[si]} ×${n}`); } + const plPoolRows = Object.keys(plByItem).map((itemId) => ({ item: label(byId[itemId]), sizes: plByItem[itemId].join(", "), total: plByItem[itemId].reduce((t, x) => t + parseInt(x.split("×")[1], 10), 0) })); + const plPoolTotal = plPoolRows.reduce((t, r) => t + r.total, 0); + type Agg = { items: number; amt: number }; + const sumBy = (arr: typeof mIssues, keyFn: (i: (typeof arr)[number]) => string) => { const m: Record = {}; for (const i of arr) { const k = keyFn(i); if (!m[k]) m[k] = { items: 0, amt: 0 }; m[k].items += i.qty; m[k].amt += i.qty * issueCost(i, byId); } return m; }; + const ccKey = (i: (typeof mIssues)[number]) => { const st = staffById[i.staffId]; return st ? (ccOf(s, st) || "—") + "|" + (st.dept || "Unknown") : "—|Unknown"; }; + const byCC = sumBy(mIssues, ccKey), byCCPrev = sumBy(pIssues, ccKey); + // Union of this month's and last month's cost centres so the Prev column reconciles to the previous-month total. + const ccKeys = [...new Set([...Object.keys(byCC), ...Object.keys(byCCPrev)])]; + const ccRows = ccKeys.map((k) => { const v = byCC[k] || { items: 0, amt: 0 }; const [cc, dept] = k.split("|"); const prev = byCCPrev[k]?.amt || 0; return { key: k, cc, dept, items: v.items, amt: v.amt, prev, delta: v.amt - prev }; }).sort((a, b) => b.amt - a.amt || b.prev - a.prev); + // What each cost-centre figure is actually made of, filed under the same key the total was + // grouped on. Grouping the detail the same way as the total is what stops a drill-down from + // disagreeing with the row that opened it — a ward manager checking their number would rather + // have no drill-down than one that doesn't add up. + const ccLines: Record = {}; + for (const i of mIssues) { const k = ccKey(i); const it = byId[i.itemId]; const unit = issueCost(i, byId); (ccLines[k] = ccLines[k] || []).push({ date: i.date, who: staffName(staffById[i.staffId], "—"), item: label(it), size: it ? String(it.sizes[i.si]) : "?", qty: i.qty, unit, amt: i.qty * unit }); } + const totAmt = mIssues.reduce((t, i) => t + i.qty * issueCost(i, byId), 0), totPrev = pIssues.reduce((t, i) => t + i.qty * issueCost(i, byId), 0), totItems = mIssues.reduce((t, i) => t + i.qty, 0); + // "Placed" = sent to the supplier; drafts (incl. auto-replenishment) are not spend yet. + // Back orders carry the parent's short lines, so they're excluded from spend to avoid counting those lines twice. + const placed = (o: (typeof s.orders)[number]) => o.status !== "Cancelled" && o.status !== "Draft" && !o.parentId; + const mOrders = s.orders.filter((o) => o.date.slice(0, 7) === month && placed(o)); + const ordSpend = mOrders.reduce((t, o) => t + orderTotal(o, byId), 0); + const byG = sumBy(mIssues, (i) => staffById[i.staffId]?.group || "Unknown"); + const groupRows = Object.entries(byG).sort((a, b) => b[1].amt - a[1].amt).map(([g, v]) => ({ g, ...v })); + const supAgg: Record = {}; + for (const o of mOrders) { const v = orderTotal(o, byId); if (!supAgg[o.supplier]) supAgg[o.supplier] = { n: 0, amt: 0, inv: [] }; supAgg[o.supplier].n++; supAgg[o.supplier].amt += v; if (o.invoice && !supAgg[o.supplier].inv.includes(o.invoice)) supAgg[o.supplier].inv.push(o.invoice); for (const rc of o.receipts) if (rc.invoice && !supAgg[o.supplier].inv.includes(rc.invoice)) supAgg[o.supplier].inv.push(rc.invoice); } + const supRows = Object.entries(supAgg).sort((a, b) => b[1].amt - a[1].amt).map(([name, v]) => ({ name, n: v.n, amt: v.amt, invoices: v.inv.join(", ") || "—" })); + const issueAgg = (m: string) => { const a = s.issues.filter((i) => i.date.slice(0, 7) === m && countsAsIssued(i) && !i.preloved); return { items: a.reduce((t, i) => t + i.qty, 0), amt: a.reduce((t, i) => t + i.qty * issueCost(i, byId), 0) }; }; + const orderAgg = (m: string) => s.orders.filter((o) => o.date.slice(0, 7) === m && placed(o)).reduce((t, o) => t + orderTotal(o, byId), 0); + const fyMonths: string[] = []; { let cur = fyStart(month + "-15").slice(0, 7); let g = 0; while (cur <= month && g++ < 13) { fyMonths.push(cur); cur = shiftMonth(cur, 1); } } + let fti = 0, fta = 0, fto = 0; + const fyRows = fyMonths.map((m) => { const ia = issueAgg(m); const ov = orderAgg(m); fti += ia.items; fta += ia.amt; fto += ov; return { m, label: monthLabel(m, { month: "short", year: "2-digit" }), items: ia.items, issued: ia.amt, orders: ov }; }); + const trendM: string[] = []; for (let i = 5; i >= 0; i--) trendM.push(shiftMonth(month, -i)); + const tv = trendM.map((m) => issueAgg(m).amt); const tmax = Math.max(...tv, 1); + const trend = trendM.map((m, i) => ({ m, label: monthLabel(m, { month: "short" }), amt: tv[i], h: tv[i] ? Math.max(Math.round((tv[i] / tmax) * 70), 4) : 2, sel: m === month })); + const byS = sumBy(mIssues, (i) => i.staffId); + const staffRows = Object.entries(byS).sort((a, b) => b[1].amt - a[1].amt).map(([sid, v]) => { const st = staffById[sid]; return { who: staffName(st, "—"), cc: ccOf(s, st), ...v }; }); + // Journal + const glAcct = s.settings.glAccount || "—"; + const jnDesc = `${s.settings.journalDesc || "Uniform issues"} ${monthLabel(month)}`; + // One debit per cost centre: departments that share a CC (or a ccOverride pointing at another dept's code) fold together. + const jnAgg: Record = {}; + for (const r of ccRows) { if (r.items <= 0) continue; const cc = r.cc === "—" ? "UNALLOCATED" : r.cc; const a = jnAgg[cc] || (jnAgg[cc] = { cc, depts: [], keys: [], items: 0, debit: 0 }); if (!a.depts.includes(r.dept)) a.depts.push(r.dept); a.keys.push(r.key); a.items += r.items; a.debit += r.amt; } + const jnRows = Object.values(jnAgg).sort((a, b) => b.debit - a.debit).map((a) => ({ cc: a.cc, dept: a.depts.join(" / "), keys: a.keys, gl: glAcct, desc: jnDesc, items: a.items, debit: a.debit })); + const jnUnallocated = jnRows.some((r) => r.cc === "UNALLOCATED"); + // Top stock + const byItem: Record = {}; const fyByItem: Record = {}; + const fy = fyStart(month + "-15"); // financial year of the selected month + // Every FY figure on this page — Top stock's, Exceptions' and Shrinkage's — stops at the end of + // the selected month. Without the upper bound, reprinting June's pack in September counts three + // months that hadn't happened when June closed, so the reprint no longer agrees with the pack + // finance was already given. + const fyCutoff = shiftMonth(month, 1) + "-01"; // exclusive: dates in `month` sort before it + for (const i of mIssues) { if (!byItem[i.itemId]) byItem[i.itemId] = { items: 0, amt: 0 }; byItem[i.itemId].items += i.qty; byItem[i.itemId].amt += i.qty * issueCost(i, byId); } + for (const i of s.issues) if (countsAsIssued(i) && !i.preloved && i.date >= fy && i.date < fyCutoff) fyByItem[i.itemId] = (fyByItem[i.itemId] || 0) + i.qty; + const mTotQty = Object.values(byItem).reduce((t, v) => t + v.items, 0); + const topRows = Object.entries(byItem).sort((a, b) => b[1].items - a[1].items).slice(0, 15).map(([id, v], n) => ({ n: n + 1, item: label(byId[id]), supplier: byId[id]?.supplier || "—", qty: v.items, val: v.amt, share: Math.round((v.items / Math.max(mTotQty, 1)) * 100) + "%", fyQty: fyByItem[id] || 0 })); + // Valuation + let negSizes = 0; + const valRows = s.catalog.map((it) => { const units = it.sizes.reduce((t, _sz, si) => { const oh = onhand(s, L, `${it.id}:${si}`); if (oh < 0) negSizes++; return t + Math.max(0, oh); }, 0); return { item: longLabel(it), sku: it.sku || "—", supplier: it.supplier || "—", units, cost: it.cost, val: units * it.cost }; }).filter((x) => x.units > 0).sort((a, b) => b.val - a.val); + const valTotUnits = valRows.reduce((t, x) => t + x.units, 0), valTot = valRows.reduce((t, x) => t + x.val, 0); + // Shrinkage + // Bounded at fyCutoff like the other FY figures: a count filed in July must not change the + // shrinkage figure on June's pack after finance has it. Pool counts are at $0, not shrinkage. + const fyTakes = s.stocktakes.filter((h) => h.date >= fy && h.date < fyCutoff && h.mode !== "preloved"); + let shU = 0, shV = 0; + const shRows = fyTakes.map((h) => { const nu = h.lines.reduce((t, l) => t + (l.counted - l.sys), 0); const nv = h.lines.reduce((t, l) => t + (l.counted - l.sys) * cost(l.itemId), 0); shU += nu; shV += nv; return { date: h.date, by: h.by, counted: h.counted, variances: h.variances, net: nu, netVal: nv }; }); + // Exceptions + const excThreshold = s.settings.exceptionHigh || 10; + const mByStaff: Record = {}; for (const i of mIssues) mByStaff[i.staffId] = (mByStaff[i.staffId] || 0) + i.qty; + const cap = setsCap(s.settings.capSets); + // Garments handed over this month past the six sets one person holds, on a coordinator's + // override. That is the one exception the ceiling itself produces, and the counter stamps it on + // the issue for this tab to find. It is read from that stamp rather than from anybody's locker + // today, because today's locker is not June's: a June pack reprinted in September would name + // whoever happens to be past the ceiling now, and saying who that is belongs to the staff + // register and the dashboard. Every stamped row in the month counts, pre-loved and since-returned + // included, because the decision was made at the counter on the day. A partial hand-in splits a + // row without changing its date, so the halves still add up to what went over. + const ovByStaff: Record = {}; + for (const i of s.issues) if (i.override && i.date.slice(0, 7) === month) ovByStaff[i.staffId] = (ovByStaff[i.staffId] || 0) + i.qty; + // Garments handed over this month outside the person's staff group, on the same tick but stamped + // apart (offGroup), so they are counted and named apart. Same month rule as above. A garment can + // be both, and then it is on both lines, because two rules were bent. + const ogByStaff: Record> = {}; + for (const i of s.issues) if (i.offGroup && i.date.slice(0, 7) === month) { const m = (ogByStaff[i.staffId] = ogByStaff[i.staffId] || {}); const n = label(byId[i.itemId]); m[n] = (m[n] || 0) + i.qty; } + // Garments handed over this month in a cut the person isn't offered, on the same tick and stamped + // apart again (offStyle). Same month rule, and the same reason for counting it apart: the ceiling, + // the staff group and the cut are three different decisions a coordinator made, and a row that + // named them all as "an override" tells whoever reads the pack nothing about which was bent. + const osByStaff: Record> = {}; + for (const i of s.issues) if (i.offStyle && i.date.slice(0, 7) === month) { const m = (osByStaff[i.staffId] = osByStaff[i.staffId] || {}); const n = label(byId[i.itemId]); m[n] = (m[n] || 0) + i.qty; } + // What each person has drawn this financial year, to the end of the selected month. It is a + // tally printed beside the month's figure, and nobody is flagged on it: what anybody may have is + // six sets held at any time, with no year in it, and a report calling somebody over on a yearly + // count sends a coordinator after a new starter the counter has kitted out quite properly. + // Counted here rather than with entUsed(), which always measures the year containing today, so a + // closed month reprints with the figures it was first printed with. fyCutoff is the one Top + // stock and Shrinkage count to, so no two tabs quote a different window for the same month. Same + // rules as entUsed(): pre-loved is free and not counted, a garment returned in good condition + // never counted, and a credited hand-in takes the good garments back off. + const fyByStaff: Record = {}; + for (const i of s.issues) if (!i.preloved && countsAsIssued(i) && i.date >= fy && i.date < fyCutoff) fyByStaff[i.staffId] = (fyByStaff[i.staffId] || 0) + i.qty; + for (const h of s.handins) if (h.credit && h.date >= fy && h.date < fyCutoff) for (const l of h.lines) fyByStaff[h.staffId] = (fyByStaff[h.staffId] || 0) - l.credited; + const excRows: { who: string; group: string; cc: string; mQty: number; fyQty: number; ovQty: number; ogQty: number; osQty: number; flags: string[]; flag: string }[] = []; + for (const st of s.staff) { + const fyQ = Math.max(0, fyByStaff[st.id] || 0); const mQ = mByStaff[st.id] || 0; const ov = ovByStaff[st.id] || 0; + const og = Object.entries(ogByStaff[st.id] || {}); const ogQ = og.reduce((t, [, n]) => t + n, 0); + const os = Object.entries(osByStaff[st.id] || {}); const osQ = os.reduce((t, [, n]) => t + n, 0); + const flags: string[] = []; + if (ov) flags.push(`Past ${cap} sets on an override — ${ov} garment${ov === 1 ? "" : "s"}`); + if (ogQ) flags.push(`Outside their staff group on an override — ${og.map(([n, q]) => `${n} ×${q}`).join(", ")}`); + if (osQ) flags.push(`Not their uniform style on an override — ${os.map(([n, q]) => `${n} ×${q}`).join(", ")}`); + if (mQ >= excThreshold) flags.push(`${mQ} items this month (threshold ${excThreshold})`); + if (flags.length) excRows.push({ who: staffName(st), group: st.group, cc: ccOf(s, st), mQty: mQ, fyQty: fyQ, ovQty: ov, ogQty: ogQ, osQty: osQ, flags, flag: flags.join(" · ") }); + } + // Overrides first, of any kind. Each one is a decision somebody made at the counter, and it is + // the row a coordinator gets asked about. Volume on its own comes after, busiest first. + excRows.sort((a, b) => Number(b.ovQty + b.ogQty + b.osQty > 0) - Number(a.ovQty + a.ogQty + a.osQty > 0) || b.mQty - a.mQty); + // Approvals + const apprRows = s.approvals.filter((a) => a.sets - a.used > 0).map((a) => { const st = staffById[a.staffId]; return { who: staffName(st, "—"), dept: st?.dept || "—", by: a.by, date: a.date, sets: a.sets, used: a.used, rem: a.sets - a.used }; }); + const apprTot = apprRows.reduce((t, a) => t + a.rem, 0); + return { repMonths, ccRows, ccLines, totAmt, totPrev, totItems, ordSpend, groupRows, supRows, fyRows, fyTot: { items: fti, issued: fta, orders: fto }, trend, staffRows, glAcct, jnDesc, jnRows, jnUnallocated, topRows, valRows, valTotUnits, valTot, negSizes, shRows, shU, shV, cap, excThreshold, excRows, apprRows, apprTot, plIssueRows, plSaved, plQty, hiRows, ragMonth, plPoolRows, plPoolTotal }; + }, [s, L, byId, staffById, month]); + + const mLbl = monthLabel(month); + const meta = `${s.settings.facility} · ${s.settings.location} · prepared ${fmtDate(s.today)}${s.settings.coordinator ? " by " + s.settings.coordinator : ""}`; + const jnTotItems = R.jnRows.reduce((t, r) => t + r.items, 0), jnTot = R.jnRows.reduce((t, r) => t + r.debit, 0); + const drillRows = useMemo(() => (drill ? drill.keys.flatMap((k) => R.ccLines[k] || []).sort((a, b) => a.date.localeCompare(b.date) || a.who.localeCompare(b.who) || a.item.localeCompare(b.item)) : []), [drill, R]); + const drillQty = drillRows.reduce((t, r) => t + r.qty, 0), drillAmt = drillRows.reduce((t, r) => t + r.amt, 0); + const csvDrill = () => drill && downloadCsv(`threadcount-cost-centre-${drill.cc.replace(/[^A-Za-z0-9]+/g, "-").toLowerCase()}-${month}.csv`, `Issues behind cost centre,${csvEsc(drill.cc)},${month}\n\n` + csvOf(["Date", "Staff", "Item", "Size", "Qty", "Unit cost", "Value"], [...drillRows.map((r) => [r.date, r.who, r.item, r.size, r.qty, r.unit.toFixed(2), r.amt.toFixed(2)] as (string | number)[]), ["TOTAL", "", "", "", drillQty, "", drillAmt.toFixed(2)]])); + + const csvOverview = () => { + let csv = `ThreadCount monthly report,${month},${csvEsc(s.settings.facility)}\n\n` + csvOf(["Cost Centre", "Department", "Items", "Amount", "Previous Month"], [...R.ccRows.map((r) => [r.cc, r.dept, r.items, r.amt.toFixed(2), r.prev.toFixed(2)] as (string | number)[]), ["TOTAL", "", R.totItems, R.totAmt.toFixed(2), R.totPrev.toFixed(2)]]); + csv += "\n" + csvOf(["Staff Group", "Items", "Amount"], R.groupRows.map((g) => [g.g, g.items, g.amt.toFixed(2)])); + csv += "\n" + csvOf(["Staff", "Cost Centre", "Items", "Amount"], R.staffRows.map((r) => [r.who, r.cc, r.items, r.amt.toFixed(2)])); + csv += "\n" + csvOf(["Supplier", "Orders", "Amount"], R.supRows.map((r) => [r.name, r.n, r.amt.toFixed(2)])); + csv += "\n" + csvOf(["FY Month", "Items Issued", "Issued Value", "Orders Placed"], [...R.fyRows.map((m) => [m.label, m.items, m.issued.toFixed(2), m.orders.toFixed(2)] as (string | number)[]), ["FY TOTAL", R.fyTot.items, R.fyTot.issued.toFixed(2), R.fyTot.orders.toFixed(2)]]); + downloadCsv(`threadcount-report-${month}.csv`, csv); + }; + const csvJournal = () => downloadCsv(`threadcount-journal-${month}.csv`, csvOf(["Cost Centre", "Department", "GL Account", "Description", "Items", "Debit"], [...R.jnRows.map((r) => [r.cc, r.dept, r.gl, r.desc, r.items, r.debit.toFixed(2)] as (string | number)[]), ["TOTAL", "", "", "", jnTotItems, jnTot.toFixed(2)]])); + const csvValuation = () => downloadCsv(`threadcount-valuation-${s.today}.csv`, `Stock valuation as at,${s.today}\n` + csvOf(["Item", "SKU", "Supplier", "Units", "Unit cost", "Value"], R.valRows.map((x) => [x.item, x.sku, x.supplier, x.units, x.cost, x.val.toFixed(2)]))); + const tabCsv: Record void> = { + Overview: csvOverview, Journal: csvJournal, Valuation: csvValuation, + "Top stock": () => downloadCsv(`threadcount-top-stock-${month}.csv`, csvOf(["Rank", "Item", "Supplier", "Qty (month)", "Value (month)", "Share", "Qty (FY)"], R.topRows.map((r) => [r.n, r.item, r.supplier, r.qty, r.val.toFixed(2), r.share, r.fyQty]))), + Shrinkage: () => downloadCsv(`threadcount-shrinkage-${month}.csv`, csvOf(["Date", "Counted by", "Lines counted", "Variances", "Net units", "Net value"], R.shRows.map((r) => [r.date, r.by, r.counted, r.variances, r.net, r.netVal.toFixed(2)]))), + Exceptions: () => downloadCsv(`threadcount-exceptions-${month}.csv`, csvOf(["Staff", "Group", "Cost centre", "Items (month)", "Items (FY)", "Flag"], R.excRows.map((r) => [r.who, r.group, r.cc, r.mQty, r.fyQty, r.flag]))), + Suppliers: () => downloadCsv(`threadcount-supplier-spend-${month}.csv`, csvOf(["Supplier", "Orders", "Value", "Invoices"], R.supRows.map((r) => [r.name, r.n, r.amt.toFixed(2), r.invoices]))), + Approvals: () => downloadCsv(`threadcount-approvals-outstanding-${s.today}.csv`, csvOf(["Staff", "Ward", "Approved by", "Date", "Sets approved", "Collected", "Remaining"], R.apprRows.map((r) => [r.who, r.dept, r.by, r.date, r.sets, r.used, r.rem]))), + "Pre-loved": () => downloadCsv(`threadcount-preloved-${month}.csv`, `Pre-loved issues ${month}\n` + csvOf(["Date", "Staff", "Item", "Size", "Qty", "Value saved"], R.plIssueRows.map((r) => [r.date, r.who, r.item, r.size, r.qty, r.saved.toFixed(2)])) + "\nHand-ins\n" + csvOf(["Date", "Staff", "Received by", "Good", "Rag", "Credit"], R.hiRows.map((r) => [r.date, r.who, r.by, r.good, r.rag, r.credit])) + "\nPool snapshot\n" + csvOf(["Item", "Sizes", "Total"], R.plPoolRows.map((r) => [r.item, r.sizes, r.total]))), + }; + const C = (t: string, r = false): Col => ({ t, r }); + const tabPrint: Record void> = { + Overview: () => printDoc(`Cost centre report — ${mLbl}`, meta, [ + { h: "Summary", html: tbl([C(""), C("", true)], [["Issued value (period)", money(R.totAmt)], ["Items issued", R.totItems], ["Supplier orders placed", money(R.ordSpend)], ["vs previous month", money(R.totPrev)]]) }, + { h: "Issued value by cost centre", html: tbl([C("CC"), C("Department"), C("Items", true), C("This period", true), C("Prev", true), C("Δ", true)], [...R.ccRows.map((r) => [r.cc, r.dept, r.items, money(r.amt), money(r.prev), signedMoney(r.delta)] as (string | number)[]), ["TOTAL", "", R.totItems, money(R.totAmt), money(R.totPrev), ""]]) }, + { h: "By staff group", html: tbl([C("Group"), C("Items", true), C("Value", true)], R.groupRows.map((g) => [g.g, g.items, money(g.amt)])) }, + { h: "By staff member", html: tbl([C("Staff"), C("CC"), C("Items", true), C("Value", true)], R.staffRows.map((r) => [r.who, r.cc, r.items, money(r.amt)])) }, + { h: "Financial year", html: tbl([C("Month"), C("Items", true), C("Issued", true), C("Orders", true)], [...R.fyRows.map((m) => [m.label, m.items, money(m.issued), money(m.orders)] as (string | number)[]), ["FY TOTAL", R.fyTot.items, money(R.fyTot.issued), money(R.fyTot.orders)]]) }, + ]), + Journal: () => printDoc(`End-of-month journal — ${mLbl}`, meta, [{ h: `One debit per cost centre — GL ${R.glAcct}`, html: tbl([C("CC"), C("Department"), C("GL"), C("Description"), C("Items", true), C("Debit", true)], [...R.jnRows.map((r) => [r.cc, r.dept, r.gl, r.desc, r.items, money(r.debit)] as (string | number)[]), ["TOTAL", "", "", "", jnTotItems, money(jnTot)]]) }]), + "Top stock": () => printDoc(`Top stock — ${mLbl}`, meta, [{ h: "Most issued items", html: tbl([C("#"), C("Item"), C("Supplier"), C("Qty", true), C("Value", true), C("Share", true), C("Qty FY", true)], R.topRows.map((r) => [r.n, r.item, r.supplier, r.qty, money(r.val), r.share, r.fyQty])) }]), + Valuation: () => printDoc(`Stock valuation — as at ${fmtDate(s.today)}`, meta, [{ h: "On-hand value by item", html: tbl([C("Item"), C("SKU"), C("Supplier"), C("Units", true), C("Unit cost", true), C("Value", true)], [...R.valRows.map((r) => [r.item, r.sku, r.supplier, r.units, money(r.cost), money(r.val)] as (string | number)[]), ["TOTAL", "", "", R.valTotUnits, "", money(R.valTot)]]) }]), + Shrinkage: () => printDoc(`Stocktake variance / shrinkage — FY to end of ${mLbl}`, meta, [{ h: `${R.shRows.length} stocktakes · net ${signedInt(R.shU)} units · ${signedMoney(R.shV)}`, html: tbl([C("Date"), C("Counted by"), C("Lines", true), C("Variances", true), C("Net units", true), C("Net value", true)], R.shRows.map((r) => [fmtDate(r.date), r.by, r.counted, r.variances, signedInt(r.net), signedMoney(r.netVal)])) }]), + Exceptions: () => printDoc(`Staff exceptions — ${mLbl}`, meta, [{ h: `Past ${R.cap} sets, outside their staff group or not their uniform style on an override, or ≥ ${R.excThreshold} items this month`, html: tbl([C("Staff"), C("Group"), C("CC"), C("Month", true), C("FY", true), C("Flag")], R.excRows.map((r) => [r.who, r.group, r.cc, r.mQty, r.fyQty, r.flag])) }]), + Suppliers: () => printDoc(`Supplier spend — ${mLbl}`, meta, [{ h: "Orders placed this period", html: tbl([C("Supplier"), C("Orders", true), C("Value", true), C("Invoices")], R.supRows.map((r) => [r.name, r.n, money(r.amt), r.invoices])) }]), + Approvals: () => printDoc(`Uncollected manager's approvals — as at ${fmtDate(s.today)}`, meta, [{ h: `${R.apprTot} sets outstanding`, html: tbl([C("Staff"), C("Ward"), C("Approved by"), C("Date"), C("Sets", true), C("Collected", true), C("Remaining", true)], R.apprRows.map((r) => [r.who, r.dept, r.by, fmtDate(r.date), r.sets, r.used, r.rem])) }]), + "Pre-loved": () => printDoc(`Pre-loved uniforms — ${mLbl}`, meta, [ + { h: `Issued free this period — saved ${money(R.plSaved)}`, html: tbl([C("Date"), C("Staff"), C("Item"), C("Size"), C("Qty", true), C("Value saved", true)], R.plIssueRows.map((r) => [fmtDate(r.date), r.who, r.item, r.size, r.qty, money(r.saved)])) }, + { h: `Hand-ins this period · ${R.ragMonth} to rag disposal`, html: tbl([C("Date"), C("Staff"), C("Received by"), C("Good", true), C("Rag", true), C("Credit")], R.hiRows.map((r) => [fmtDate(r.date), r.who, r.by, r.good, r.rag, r.credit])) }, + { h: `Pool snapshot — ${R.plPoolTotal} items at $0 book value`, html: tbl([C("Item"), C("Sizes"), C("Total", true)], R.plPoolRows.map((r) => [r.item, r.sizes, r.total])) }, + ]), + }; + function printEomPack() { + const sections = [ + { h: "Summary", html: tbl([C(""), C("", true), C(""), C("", true)], [["Issued value", money(R.totAmt), "Items issued", R.totItems], ["Supplier orders placed", money(R.ordSpend), "Stock on hand value", money(R.valTot)], ["Shrinkage (FY to end of month)", signedMoney(R.shV), "Stocktakes counted (FY)", R.shRows.length]]) }, + { h: "Cost centre summary", html: tbl([C("CC"), C("Department"), C("Items", true), C("Value", true)], [...R.jnRows.map((r) => [r.cc, r.dept, r.items, money(r.debit)] as (string | number)[]), ["TOTAL", "", jnTotItems, money(jnTot)]]) }, + { h: `Journal — one debit per cost centre (GL ${R.glAcct})`, html: tbl([C("CC"), C("Description"), C("Debit", true)], R.jnRows.map((r) => [r.cc, r.desc, money(r.debit)])) }, + { h: "Top stock", html: tbl([C("Item"), C("Qty", true), C("Value", true)], R.topRows.slice(0, 10).map((r) => [r.item, r.qty, money(r.val)])) }, + ]; + // Finance is promised shrinkage in this pack, and the net figure is in the summary above every + // month. The count-by-count table only turns up when counts were actually filed, the same rule + // the exceptions and approvals sections below follow — a heading over an empty table tells + // finance nothing and costs them a page. + if (R.shRows.length) sections.push({ h: `Shrinkage — stocktake variance, FY to end of ${mLbl} · net ${signedInt(R.shU)} units · ${signedMoney(R.shV)}`, html: tbl([C("Date"), C("Counted by"), C("Variances", true), C("Net units", true), C("Net value", true)], R.shRows.map((r) => [fmtDate(r.date), r.by, r.variances, signedInt(r.net), signedMoney(r.netVal)])) }); + if (R.excRows.length) sections.push({ h: "Staff exceptions", html: tbl([C("Staff"), C("Cost centre"), C("Flag")], R.excRows.map((r) => [r.who, r.cc, r.flag])) }); + if (R.apprRows.length) sections.push({ h: "Uncollected manager's approvals", html: tbl([C("Staff"), C("Approved by"), C("Remaining sets", true)], R.apprRows.map((r) => [r.who, r.by, r.rem])) }); + printDoc(`Month-end pack — ${mLbl}`, meta, sections); + } + const delta = (d: number) => 0 ? "var(--color-accent-700)" : "var(--color-neutral-700)" }}>{(d >= 0 ? "+" : "−") + money(Math.abs(d)).slice(1)}; + const R2 = (n: number) => ({ textAlign: "right" as const, fontWeight: n }); + /* A ward manager rings the linen room asking why their number doubled this month, and until now + the coordinator had nothing on the screen to answer with. The cost centre is a button: it opens + the issues that make the figure beside it — who, which garment, which size, when, what it cost. + It is the cell and not the row because a row that only answers to a click is unreachable from + the keyboard, and its name carries the figure so it is clear what the button opens. */ + const drillBtn = (cc: string, dept: string, keys: string[], items: number, amt: number) => { + // The Overview prints an em dash for staff who have no cost centre; the journal calls those + // UNALLOCATED, and that is the word to say out loud rather than "issues behind —". + const name = cc === "—" ? "UNALLOCATED" : cc; + return ( + + ); + }; + + return ( +
+ + + + + + +
+ {TABS.map((t) => )} +
+ + {tab === "Overview" && ( + <> + {/* Five figures rather than the four that were here: the pre-loved pool saves the ward + real money every month and it was a sentence under the strip, which is not where + anybody looks for a number. */} + R.totPrev ? "Up" : "Down"} ${money(Math.abs(R.totAmt - R.totPrev))} on last month` }, + { val: R.plQty, label: "Pre-loved issued (free)", note: `Saved ${money(R.plSaved)} at catalogue cost` }, + ]} /> +
+
+ +
+
+ {th("Cost centre")}{th("Department")}{th("Items", true)}{th("This period", true)}{th("Prev", true)}{th("Δ", true)} + + {R.ccRows.map((r) => )} + + +
{drillBtn(r.cc, r.dept, [r.key], r.items, r.amt)}{r.dept}{r.items}{money(r.amt)}{money(r.prev)}{delta(r.delta)}
TOTAL{R.totItems}{money(R.totAmt)}{money(R.totPrev)}
+ {R.ccRows.length === 0 && No issues recorded in this period yet.} +
+
+ +
+
+ {th("Month")}{th("Items issued", true)}{th("Issued value", true)}{th("Orders placed", true)} + + {R.fyRows.map((m) => )} + + +
{m.label}{m.items}{money(m.issued)}{money(m.orders)}
FY TOTAL{R.fyTot.items}{money(R.fyTot.issued)}{money(R.fyTot.orders)}
+
+
+
+
+ + {R.groupRows.length === 0 ?
Nothing issued in this period.
: ( +
+ {R.groupRows.map((g) => ( +
+
{g.g}
{g.items} item{g.items === 1 ? "" : "s"}
+
{money(g.amt)}
+
+ ))} +
+ )} +
+ + {R.staffRows.length === 0 ?
Nothing issued in this period.
: ( +
+ {R.staffRows.map((r, i) => ( +
+
{r.who}
{r.cc || "no cost centre"} · {r.items} item{r.items === 1 ? "" : "s"}
+
{money(r.amt)}
+
+ ))} +
+ )} +
+ + {R.supRows.length === 0 ?
No supplier orders placed this period.
: ( +
+ {R.supRows.map((r) => ( +
+
{r.name}
{r.n} order{r.n === 1 ? "" : "s"}
+
{money(r.amt)}
+
+ ))} +
+ )} +
+ +
+
+ {/* Each bar changes the month the whole page is reporting on, so it is a button — + a clickable
put the only way of moving between months out of reach of the + keyboard. The bar itself is decoration; the name says the month and the figure. */} + {R.trend.map((b) => ( + + ))} +
+
+ +
+
+ + )} + + {tab === "Journal" && ( + /* A staff member with no cost centre lands in UNALLOCATED, and finance cannot post that + line — so the panel carries the rule and the mark rather than leaving the warning to a + red sentence under a table nobody scrolls to. */ + Export journal CSV}> +
+
+ {th("Cost centre")}{th("Department")}{th("GL account")}{th("Description")}{th("Items", true)}{th("Debit", true)} + + {R.jnRows.map((r) => )} + + +
{drillBtn(r.cc, r.dept, r.keys, r.items, r.debit)}{r.dept}{r.gl}{r.desc}{r.items}{money(r.debit)}
TOTAL{jnTotItems}{money(jnTot)}
+
+
Set the GL account and description under Settings → General.{R.jnUnallocated && UNALLOCATED = staff with no cost centre — set their department or override on the Staff Register before posting.}
+
+ )} + + {tab === "Top stock" && ( + +
+
+ {th("#")}{th("Item")}{th("Supplier")}{th("Qty (month)", true)}{th("Value (month)", true)}{th("Share", true)}{th("Qty (FY)", true)} + {R.topRows.map((r) => )} +
{r.n}{r.item}{r.supplier}{r.qty}{money(r.val)}{r.share}{r.fyQty}
+ {R.topRows.length === 0 && Nothing issued this period.} +
+
+ )} + + {tab === "Valuation" && ( + 0} + aside={R.negSizes > 0 ? `${R.negSizes} size${R.negSizes === 1 ? "" : "s"} negative on hand` : "Priced at catalogue cost"} + right={}> +
+
+ {th("Item")}{th("SKU")}{th("Supplier")}{th("Units on hand", true)}{th("Unit cost", true)}{th("Value", true)} + + {R.valRows.map((r, i) => )} + + +
{r.item}{r.sku}{r.supplier}{r.units}{money(r.cost)}{money(r.val)}
TOTAL{R.valTotUnits}{money(R.valTot)}
+
+ {R.negSizes > 0 &&
{R.negSizes} size{R.negSizes === 1 ? "" : "s"} are negative on hand and are counted as 0 in this valuation — run a stocktake or record the missing receipt.
} +
+ )} + + {tab === "Shrinkage" && ( + <> + {/* Stock that has gone missing is money finance has to be told about, so the two figures + that carry it take the rule and the mark when the net is down, not just a red number. */} + + +
+
+ {th("Date")}{th("Counted by")}{th("Lines counted", true)}{th("Variances", true)}{th("Net units", true)}{th("Net value", true)} + {R.shRows.map((r, i) => )} +
{fmtDate(r.date)}{r.by}{r.counted}{r.variances}{signedInt(r.net)}{signedMoney(r.netVal)}
+ {R.shRows.length === 0 && No stocktakes filed in this financial year up to the end of this month.} +
+
+ + )} + + {tab === "Exceptions" && ( + 0} + aside={R.excRows.length > 0 ? `${R.excRows.length} to look at` : "No overrides, nobody at the volume threshold"}> +
+
+ {th("Staff")}{th("Group")}{th("Cost centre")}{th("Items (month)", true)}{th("Items (FY)", true)}{th("Flag")} + {R.excRows.map((r, i) => )} +
{r.who}{r.group}{r.cc}{r.mQty}{r.fyQty}{r.flags.map((f, j) => {f})}
+ {R.excRows.length === 0 && No exceptions this period.} +
+
Items (FY) is a running tally, not an allowance.
+
+ )} + + {tab === "Suppliers" && ( + +
+
+ {th("Supplier")}{th("Orders", true)}{th("Value", true)}{th("Invoices")} + {R.supRows.map((r) => )} +
{r.name}{r.n}{money(r.amt)}{r.invoices}
+ {R.supRows.length === 0 && No supplier orders placed this period.} +
+
+ )} + + {tab === "Pre-loved" && ( + <> + +
+ {R.plIssueRows.length === 0 ? Nothing issued from the pool this period. : ( +
+ {th("Date")}{th("Staff")}{th("Item")}{th("Size")}{th("Qty", true)}{th("Value saved", true)} + {R.plIssueRows.map((r, i) => )} +
{fmtDate(r.date)}{r.who}{r.item}{r.size}{r.qty}{money(r.saved)}
+ )} +
+
+ +
+ {R.hiRows.length === 0 ? No hand-ins recorded this period. : ( +
+ {th("Date")}{th("Staff")}{th("Received by")}{th("Good", true)}{th("Rag", true)}{th("Allowance")} + {R.hiRows.map((r, i) => )} +
{fmtDate(r.date)}{r.who}{r.by}{r.good}{r.rag}{r.credit}
+ )} +
+
+ +
+ {R.plPoolRows.length === 0 ? The pool is empty — record a hand-in from Issue Stock or a staff profile. : ( +
+ {th("Item")}{th("Sizes on hand")}{th("Total", true)} + {R.plPoolRows.map((r, i) => )} +
{r.item}{r.sizes}{r.total}
+ )} +
+
+ + )} + {tab === "Approvals" && ( + 0} + aside={R.apprTot > 0 ? `${R.apprTot} set${R.apprTot === 1 ? "" : "s"} outstanding` : "Everything approved has been collected"}> +
+
+ {th("Staff")}{th("Ward")}{th("Approved by")}{th("Date")}{th("Sets approved", true)}{th("Collected", true)}{th("Remaining", true)} + + {R.apprRows.map((r, i) => )} + + +
{r.who}{r.dept}{r.by}{fmtDate(r.date)}{r.sets}{r.used}{r.rem}
TOTAL OUTSTANDING{R.apprTot} sets
+ {R.apprRows.length === 0 && No uncollected approvals.} +
+
+ )} + {drill && ( + setDrill(null)} + sub={`${drill.dept} · ${drillQty} item${drillQty === 1 ? "" : "s"} · ${money(drillAmt)}`}> + {drillRows.length === 0 ? Nothing was issued against this cost centre in {mLbl}. : ( +
+ {th("Date")}{th("Staff")}{th("Item")}{th("Size")}{th("Qty", true)}{th("Unit cost", true)}{th("Value", true)} + + {drillRows.map((r, i) => )} + + +
{fmtDate(r.date)}{r.who}{r.item}{r.size}{r.qty}{money(r.unit)}{money(r.amt)}
TOTAL{drillQty}{money(drillAmt)}
+ )} +
+ {drillRows.length > 0 && } + +
+
+ )} +
Print and Export CSV follow the selected tab — click a cost centre for the issues behind it.
+
+ ); +} diff --git a/app/app/requests/page.tsx b/app/app/requests/page.tsx new file mode 100644 index 0000000..ea3ebd8 --- /dev/null +++ b/app/app/requests/page.tsx @@ -0,0 +1,862 @@ +"use client"; +/* Staff requests, from the linen room's side. + * + * The counter's queue. A request only appears here as something to act on once a ward manager has + * approved it — anything still `awaiting` is shown, greyed, so the linen room can see what is + * coming without being able to do anything about it. That asymmetry is the point of the whole + * flow: approval is the ward's, fulfilment is the linen room's, and neither can do the other's job. + * + * A request covers as many garments as the person asked for, one line each, and the manager can + * knock back individual lines — the tunic and the trousers yes, the fleece no. So every screen + * here has to keep two ideas apart: `lines` is the record of what was asked, `bag` is what is + * actually picked. Picking off `lines` would put a garment the ward refused into somebody's hands, + * so the pick, the count, the slip and the collection code are all built from `bag`. + */ +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useSnap } from "@/lib/client"; +import { openSlip } from "@/components/dialogs"; +import { PageHead, Empty, ErrorLine, Field } from "@/components/ui"; +import { csvEsc, csvOf, facilityDate, fmtDate, formatInZone, genderLabel, slipLive, type Snapshot } from "@/lib/compute"; +import { downloadCsv } from "@/lib/print"; +import type { ReqLine } from "@/lib/staffdata"; +import { NEEDS_STAFF, OPEN_REQUEST, WAITLIST_HOLD_HOURS, holdEndsAt, holdExpired, statusText } from "@/lib/staffreq"; + +type Msg = { id: string; fromStaff: boolean; authorName: string; body: string; at: string }; +type Ev = { id: string; label: string; meta: string; actorName: string; at: string }; +type Req = { + id: string; code: string; status: string; staffId: string; staffName: string; staffNum: string; ward: string; + /** Everything asked for, declines included — and separately the ones that are actually a pick. */ + lines: ReqLine[]; bag: ReqLine[]; + summary: string; garments: number; lineCount: number; decision: string | null; + reason: string; note: string; + managerName: string; + /** Which person on the register the approver is — not just how their name is spelled. A manager + * may approve a request raised for herself, and the only thing that can show that happened is + * this id beside the wearer's: two spellings of one name tell nobody anything. Null while + * nobody has been asked. */ + managerId: string | null; + declineReason: string | null; route: string | null; + collectCode: string | null; holdUntil: string; signerName: string | null; signerRole: string | null; + signedAt: string | null; claimedAt: string | null; + /** Who raised it, and which person on the register that is. The id is what the approver list is + * built on: a ward register carries people who share a name, and telling them apart by spelling + * is how the wrong one gets dropped out of a dropdown. Null when the wearer raised it herself, + * and null when the linen room raised it at the counter — that one is stamped with the + * coordinator's own account, which is not on the ward register at all. Neither is a name this + * screen could have offered anyway. */ + raisedById: string | null; raisedByName: string; + createdAt: string; decidedAt: string | null; messages: Msg[]; events: Ev[]; +}; +/** One name in the re-address dropdown: who they are, whether anything can actually reach them, + * and the words the coordinator reads before picking them. */ +type ApproverChoice = { id: string; reachable: boolean; label: string }; +type Dispute = { id: string; body: string; staffName: string; staffNum: string; ward: string; at: string }; +type Cycle = { id: string; dueBy: string; openedBy: string; openedAt: string; answers: number }; +type Waiting = { id: string; staffName: string; staffNum: string; ward: string; item: string; size: string; since: string; offeredAt: string | null }; +type Damage = { id: string; kind: string; note: string; photoId: string | null; staffId: string; staffName: string; staffNum: string; ward: string; item: string; size: string; requestCode: string; at: string }; +type Shortfall = { + id: string; staffId: string; staffName: string; staffNum: string; ward: string; + item: string; size: string; onRecord: number; confirmed: number; short: number; at: string; +}; +/* `requestLimit` and `moreRequests` are the endpoint saying how much of the queue this is. It reads + one row past its own ceiling so that "there are older ones than these" is a fact rather than a + guess off a full page — and nothing here read it, so the list just stopped at the newest 400 with + no word to anybody. Every tab on this screen is a view of that same set, so a request from before + the cut-off is on none of them and in nothing exported from them. */ +type Payload = { requests: Req[]; disputes: Dispute[]; cycle: Cycle | null; shortfalls: Shortfall[]; waiting: Waiting[]; damage: Damage[]; requestLimit: number; moreRequests: boolean }; + +const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; + +/* What a tab is called once it has to be named away from its own button — over the queue it heads, + and at the top of a file exported off it. One spelling in one place, so a spreadsheet that has + left the building can never disagree with the screen about which view it came from. */ +const TAB_TITLE = { + todo: "To do", noapprover: "Needs an approver", open: "Open", all: "All", + queries: "Record queries", damage: "Damage", cycles: "Kit check & waitlist", +} as const; + +/** A request with nothing in its `managerName` never got an approver at all. + * + * That happens the moment a manager raises for one of their own reports: they would otherwise be + * approving their own raise, so the staff app sends it up a level — and when there is nobody above + * them, or the one above is themselves, it is created with no approver and waits here. Nobody on the ward can move it, so if this + * screen did not say so out loud it would simply sit in the queue for ever. */ +const stranded = (r: Req) => r.status === "awaiting" && !r.managerName; + +/** Is this bag going out on the ward round rather than waiting at the counter? Decides which of + * the two slips is the one worth printing. */ +const onRound = (r: Req) => r.status === "round" || r.status === "delivered"; + +/* Who a waiting request can be handed to, and what has to be said about each name before it is + * picked. + * + * The wearer is on the list like anybody else: anyone may approve for themselves (the owner's + * decision), and it is marked Self-approved wherever it shows. The option says out loud that this + * is a self-approval, because otherwise the coordinator is choosing between two spellings of the + * same person and finds out what they did from the timeline months later. + * + * The person who raised it is off the list altogether, and that one has no way back in. A + * manager asking for one of her own reports' garments is the whole reason the request escalated + * and landed on this tab with nobody to approve it — and she is the obvious pick, because she IS + * the wearer's manager on the register, with nothing on the row to say the ask came from her. + * Handing it back to her would have one person do both halves of a decision the ward is told two + * people made, so it is turned down the moment the button is pressed. Leaving her in the list + * made the Needs an approver tab offer the one name on it certain to fail, on the tab that + * exists to fix exactly that. It is her id that keeps her off the list and nothing else, which is + * why the queue carries it: a ward can hold two people spelled the same way, only one of them + * raised this, and the counter refuses on the id too. + * + * Reachability is the other half, and nothing refuses it: a manager with no staff-app account + * cannot be asked at all. The approval e-mail has nowhere to go and they cannot sign in to + * decide it, so re-addressing to one of them puts the request straight back in the dead end it + * was being rescued from — except that it does not come back to this tab, because it now has a + * name against it. Said on the option, before it is chosen. A printed code only counts as a way in + * while the activation would still take it, which is slipLive's call and nobody else's: "a code is + * outstanding" is all the register holds, and reading that as live had the dropdown calling a slip + * worth chasing that the person would be turned away with, while the staff register, looking at + * the same person, said they had no staff app at all. + * + * The raiser rule is the counter's rule said a second time, in a screen, and two copies of a rule + * agree only until one of them is edited. The queue could settle it by arriving with the answer + * already worked out — the ids this particular request can be sent to, decided where the refusal + * itself lives — and then a name is on this list exactly when it would be accepted, and this + * function is left with nothing to do but the words. */ +function approverChoices(s: Snapshot, r: Req): ApproverChoice[] { + return s.staff + .filter((x) => !x.inactive && x.first && x.id !== r.raisedById && (x.id !== r.staffId || x.managerId === x.id)) + .map((x) => ({ + id: x.id, + reachable: !!x.selfEmail, + label: `${`${x.first} ${x.last}`.trim()}${x.dept ? ` · ${x.dept}` : ""}` + + (x.id === r.staffId ? " · this request is theirs — self-approval" : "") + + (x.selfEmail ? "" : x.selfCode && slipLive(x.selfCodeAt, s.today, s.tz) ? " · code printed, not used yet" : " · no staff-app account"), + })); +} + +/** The whole ask, line by line, with the manager's answer against each garment. + * + * The declines stay on the list rather than being dropped: the wearer will ask why they got two + * things and not three, and the person at the counter needs the answer in front of them. They are + * struck through so nobody picks one by mistake. */ +function LineList({ r }: { r: Req }) { + const refused = r.lines.filter((l) => l.status === "declined").length; + const note = + r.status === "declined" ? "Nothing to pick — every line was declined." + : r.status === "awaiting" ? `${plural(r.garments, "garment", "garments")} asked for. Nothing is picked until the ward has decided.` + : refused > 0 ? `In the bag: ${plural(r.garments, "garment", "garments")} across ${plural(r.bag.length, "line", "lines")}. The ${refused === 1 ? "declined line is" : `${refused} declined lines are`} not picked.` + : `In the bag: ${plural(r.garments, "garment", "garments")}.`; + return ( +
+ {r.lines.map((l) => { + const off = l.status === "declined"; + return ( +
+ + {l.qty} × {l.item}{l.gender && l.gender !== "Unisex" ? ` (${genderLabel(l.gender)})` : ""} — {l.size} + + {l.statusLabel} + {off && l.declineReason && {l.declineReason}} +
+ ); + })} +
{note}
+
+ ); +} + +export default function RequestsPage() { + const { s, mutate } = useSnap(); + const [data, setData] = useState(null); + const [tab, setTab] = useState<"todo" | "noapprover" | "open" | "all" | "queries" | "damage" | "cycles">("todo"); + const [dueBy, setDueBy] = useState(""); + const [openId, setOpenId] = useState(null); + const [reply, setReply] = useState(""); + const [hold, setHold] = useState(""); + /** The replacement approver picked for a stranded `awaiting` request. */ + const [reassign, setReassign] = useState(""); + const [err, setErr] = useState(""); + + /* "Still loading" and "the queue never arrived" look identical from the outside, and this screen + is the linen room's work list — reading it as empty when the fetch failed means a ward waits on + a request nobody knows about. So the failure is said out loud and can be retried. */ + const [loadErr, setLoadErr] = useState(""); + const load = useCallback(async () => { + setLoadErr(""); + try { + const r = await fetch("/api/requests"); + if (!r.ok) { const j = await r.json().catch(() => ({})); setLoadErr(j.error || "Couldn’t load the request queue."); return; } + setData(await r.json()); + } catch { + setLoadErr("Couldn’t reach the server — the request queue isn’t loaded."); + } + }, []); + useEffect(() => { void load(); }, [load]); + + async function act(op: string, payload: unknown) { + setErr(""); + const r = await mutate(op, payload); + if (!r.ok) { setErr(r.error); return false; } + await load(); + return true; + } + + /* What goes on the printed slip. + * + * The bag, never the whole ask: a slip that listed a garment the ward declined would have + * somebody hunting the shelf for it, and the person signing would sign for three things and get + * two. The request's own code goes in the order-number field and the collection code is printed + * beside the name, because one code now covers several garments and it is the only thing that + * ties this piece of paper to that bag. */ + const slipFor = (r: Req) => ({ + staffName: r.staffName, dept: r.ward, deliverTo: r.ward, + sets: r.garments, po: r.code, code: r.collectCode || "", + // The cut goes on the slip. Two garments can share a name and differ only by it — an + // Ambassador Shirt comes men's and ladies, on different style codes and different shelves — + // and a line reading "1 × Ambassador Shirt — M" gives whoever is picking no way to tell which, + // which is a wrong garment in the bag and a return later. Omitted for unisex, where it is noise. + lines: r.bag.map((l) => `${l.qty} × ${l.item}${l.gender && l.gender !== "Unisex" ? ` (${genderLabel(l.gender)})` : ""} — ${l.size}`).join("\n"), + dateReceived: s.today, requestedBy: r.staffNum, + deliveredBy: s.settings.coordinator, dateTime: s.today, + }); + + /* The dropdown's list, worked out once and only for the row that is actually open. + * + * Every name on it is a walk of the whole register, and it used to be built for every waiting + * request on screen although only the open one can show a dropdown — on a busy register, forty + * walks to draw one list. The reply box further down shares this component's state, so that + * whole pass ran again on every letter typed into a message to a ward. */ + const openReq = data?.requests.find((r) => r.id === openId) ?? null; + const choices = useMemo( + () => (openReq && openReq.status === "awaiting" ? approverChoices(s, openReq) : []), + [s, openReq], + ); + + if (!data) return ( +
+ + {loadErr ? ( + <> + +
+ + ) : Loading…} +
+ ); + + // "To do" is the linen room's actual work queue: approved and not yet handed over. + const todo = data.requests.filter((r) => ["accepted", "picking", "ready", "round"].includes(r.status)); + const open = data.requests.filter((r) => OPEN_REQUEST.has(r.status as never)); + // Requests nobody was ever asked to approve. Their own tab because they are the only thing on + // this screen that is stuck rather than merely waiting, and the fix — give it an approver — is + // the linen room's to make and nobody else's. + const noApprover = data.requests.filter(stranded); + const rows = tab === "todo" ? todo : tab === "noapprover" ? noApprover : tab === "open" ? open : tab === "all" ? data.requests : []; + /* The queue arrives newest first and stops at its ceiling, so what is missing is always the + oldest — and old is exactly what a stranded request or a bag nobody collected becomes. Every + request tab is a narrowing of that one set, so every one of them carries the mark, not just + All: a coordinator who cleared Needs an approver to a bare 0 would take the ward's stuck + requests to be dealt with while the longest-stuck of them sat past the cut-off, unseen. */ + const more = data.moreRequests ? "+" : ""; + const inLoaded = data.moreRequests ? ` among the most recent ${data.requestLimit} requests` : ""; + + /* Export — the tab on screen, and nothing else. + + Every tab here is a view of the same queue narrowed a different way, so a coordinator who has + narrowed to To do and hits Export means that work queue, not eighteen months of requests. There + is no search box on this screen, so the tab is the only filter in force and honouring it is the + whole job. The file is named after the tab as well: four exports all called + threadcount-requests-2026-09-11.csv land in one Downloads folder as "(1)" and "(2)", and by + Monday nobody can say which one the ward was sent. + + The last three tabs are not narrowings of the request queue at all — a record query, a damage + report and a kit-check answer share no columns with a request and no columns with each other — + so each writes its own table rather than being forced into one shape with most cells empty. + Kit check & waitlist is two registers on one screen, so it writes two tables into the one file, + the way the pre-loved report does; folding them together would put a garment somebody is + queueing for in the same column as a garment somebody has lost. */ + const shown = tab === "queries" ? data.disputes.length + : tab === "damage" ? data.damage.length + : tab === "cycles" ? data.shortfalls.length + data.waiting.length + : rows.length; + + function exportCsv() { + // Hoisted, so the checker cannot see the early return above that already proved this is here — + // and it is right not to: a function declaration can be called from anywhere in the body. The + // button is only rendered once the data has loaded, so this never fires; it is here to make the + // guarantee local to the function that relies on it. + if (!data) return; + /* Full date and 24-hour time, in the facility's zone, with the zone named at the top of the + file. The screen prints "9 Sep" because you read it in order; a spreadsheet gets re-sorted the + moment it lands, and "9 Sep, 14:32" sorts as text into nonsense and carries no year at all. + Empty rather than an em dash where there is no instant — a dash in a spreadsheet cell is only + noise to filter around. */ + const when = (iso: string | null) => + iso ? `${facilityDate(iso, s.tz)} ${formatInZone(iso, s.tz, { hour: "2-digit", minute: "2-digit", hour12: false, hourCycle: "h23" })}` : ""; + /* The header block above the column headings, so a file that has left the building still says + which view it is, when it was taken and what zone its times are in. */ + const preamble = (facts: [string, string | number][]) => + facts.map(([k, v]) => `${csvEsc(k)},${typeof v === "number" ? v : csvEsc(v)}`).join("\n") + "\n\n"; + + if (tab === "queries") { + downloadCsv(`threadcount-record-queries-${s.today}.csv`, + preamble([["Record queries", "Raised against a staff record, not yet sorted"], ["Exported", s.today], ["Times shown in", s.tz], ["Queries in this file", data.disputes.length]]) + + csvOf(["Raised", "Staff no.", "Staff member", "Ward", "What they say is wrong"], + data.disputes.map((d) => [when(d.at), d.staffNum, d.staffName, d.ward, d.body]))); + return; + } + + if (tab === "damage") { + downloadCsv(`threadcount-damage-${s.today}.csv`, + preamble([["Damage reported", "Not yet handed in at the counter"], ["Exported", s.today], ["Times shown in", s.tz], ["Reports in this file", data.damage.length]]) + + csvOf(["Reported", "Staff no.", "Staff member", "Ward", "Garment", "Size", "Damage", "What they said", "Replacement requested", "Photo"], + // The issue a report was raised against can be deleted, and the screen says so in words + // rather than showing a blank. A blank cell here would read as a gap in the export. + data.damage.map((d) => [when(d.at), d.staffNum, d.staffName, d.ward, d.item || "Garment no longer on file", d.size, d.kind, d.note, d.requestCode, d.photoId ? "Yes" : "No"]))); + return; + } + + if (tab === "cycles") { + const c = data.cycle; + downloadCsv(`threadcount-kit-check-${s.today}.csv`, + preamble([ + ["Kit check and waitlist", c ? `Running — due by ${c.dueBy}` : "No kit check running"], + ["Opened by", c ? c.openedBy || "—" : ""], + ["Answers in", c ? c.answers : 0], + ["Exported", s.today], + ["Times shown in", s.tz], + ]) + // Nothing in this table has changed anybody's record, exactly as the screen says. It is the + // working list for squaring the register one garment at a time, so it goes out with the + // person and the size on every row rather than as a count of answers. + + "What people couldn't account for\n" + + csvOf(["Staff no.", "Staff member", "Ward", "Garment", "Size", "On record", "Confirmed", "Short", "Answered"], + data.shortfalls.map((f) => [f.staffNum, f.staffName, f.ward, f.item, f.size, f.onRecord, f.confirmed, f.short, when(f.at)])) + + "\nWaiting for a size\n" + + csvOf(["Staff no.", "Staff member", "Ward", "Garment", "Size", "Waiting since", "Offered", "Held until", "Hold"], + data.waiting.map((w) => { + // The deadline is computed the one way the product computes it, so a file taken off + // this screen can never disagree with the screen about whose garment it still is. + const ends = holdEndsAt(w.offeredAt); + return [w.staffNum, w.staffName, w.ward, w.item, w.size, when(w.since), when(w.offeredAt), + ends ? when(ends.toISOString()) : "", + !w.offeredAt ? "Not offered yet" : holdExpired(w.offeredAt) ? "Lapsed — offer to the next person" : "Held"]; + }))); + return; + } + + /* A row is a garment, not a request. + + A request covers as many garments as the person asked for and the manager decides each one + separately — the tunic and the trousers yes, the fleece no. One row per request could only + carry the rollup, "2 of 3 approved", and the question this file is opened to answer is + precisely the one that would then be missing: which garment was refused, and why. So the + request's own facts repeat down its lines. That repetition is what makes the file worth + having in a spreadsheet — every declined fleece in the hospital is one filter on Line + decision — and the count in the preamble says how many requests those rows came from, so + nobody reads nineteen rows as nineteen requests. + + The two decisions keep their own words, because they are not the same decision and this file + goes to a ward manager. A LINE is approved or declined, and Line decision is the word the + line already carries from lineStatusLabel(). A REQUEST is accepted or declined, and Request + status is statusText()'s label — the same words as the tag on the row. Decision summary is + decisionSummary()'s rollup and nothing recomputed here. Request decline reason is the + request-level one, which is as often the linen room withdrawing an unapprovable request as + it is the ward refusing the whole ask. + + A blank Approver is the Needs an approver tab's own definition — nobody was ever asked — so + those requests stay identifiable after they have been filed away with the rest. Approver is + the wearer says a manager was asked to sign for her own kit, which the product allows and + the ward may reasonably want to see; two matching names in adjacent columns is not something + anybody spots reading down a file, and on a ward where two people share a name it is not + even true. It is a fact about who was asked, so it is filled in on a request still waiting + as much as on one already decided. + + Held until is left out: it is free text somebody typed at the counter ("Fri 6pm"), and a + column of that sorts into nonsense beside four real dates. Ward is the wearer's ward, as the + row on screen states it; a bag already out on a round was routed to the ward she was on when + the trolley loaded, which after a transfer is a different one. */ + const title = TAB_TITLE[tab]; + downloadCsv(`threadcount-requests-${tab === "noapprover" ? "needs-an-approver" : tab}-${s.today}.csv`, + preamble([ + ["Ward requests", title], + ["Exported", s.today], + ["Times shown in", s.tz], + ["Requests in this file", rows.length], + // A file that is short of the register says so in its own header, because the person who + // opens it in three months has no screen beside it to work that out from. + ...(data.moreRequests + ? ([["Older requests not in this file", `The screen holds the most recent ${data.requestLimit} requests and there are older ones than those`]] as [string, string][]) + : []), + ["Rows", "One per line on the request — a request for a tunic and two pairs of trousers is two rows, and the pairs are a Qty of 2 on the second"], + ]) + + csvOf(["Request", "Raised", "Staff no.", "Staff member", "Ward", "Raised by", "Reason", "Note", "Request status", "Approver", "Approver is the wearer", "Decision summary", "Decided", "Request decline reason", "Collection code", "Garment", "Cut", "Size", "Qty", "Line decision", "Line decline reason"], + rows.flatMap((r) => { + const req: (string | number)[] = [ + r.code, when(r.createdAt), r.staffNum, r.staffName, r.ward, r.raisedByName, r.reason, r.note, + statusText(r).label, r.managerName, r.managerId && r.managerId === r.staffId ? "Yes" : "", + r.decision ?? "", when(r.decidedAt), r.declineReason ?? "", r.collectCode ?? "", + ]; + /* A request with no lines on it still has to appear. It is only ever a half-written raise + or one whose garment was deleted from the catalogue, but it is sitting in somebody's + queue, and a file built by walking lines would drop it silently — which on the Needs an + approver tab would hide the one kind of request nobody else can rescue. */ + const lines: (ReqLine | null)[] = r.lines.length ? r.lines : [null]; + return lines.map((l) => [...req, + l ? l.item : "", l ? genderLabel(l.gender) : "", l ? l.size : "", l ? l.qty : "", + l ? l.statusLabel : "", l ? l.declineReason ?? "" : ""]); + }))); + } + + /* The two optional names are for the actions that repeat down the queue. A button reading "Print + order form" says nothing about which request it belongs to once you are hearing it rather than + looking at it, and the counter shares a printer — so what a button is about to put on paper is + worth knowing before it is pressed. */ + const Btn = ({ label, onClick, primary, ariaLabel, title }: { label: string; onClick: () => void; primary?: boolean; ariaLabel?: string; title?: string }) => ( + + ); + + return ( +
+ + {/* One button, and it writes the tab you are looking at. The count is said out loud wherever + the tab is a narrowing, because that is the difference between a file of this morning's + work and a file of the whole register, and the two are indistinguishable once they are + attachments on an email. Not on Kit check & waitlist: that tab counts one of its two + lists, and a number here that disagreed with the number on the tab would be read as a + bug in the file rather than as two different things being counted. */} + + + + + + {/* A stranded request is the one thing here that nobody else can rescue. It never reaches the + To do queue, it looks like any other greyed `awaiting` row in Open, and the ward is sitting + waiting on an approval that was never asked for — so it is said before the tabs rather + than found by opening one. */} + {noApprover.length > 0 && tab !== "noapprover" && ( + /* A rule down the edge, a mark and a heavier figure rather than a red box. The primary + button an inch away is the same red, so a red outline on its own is not a signal — and + this is the one thing on the screen nobody but the linen room can rescue. */ +
+ + + +
+ )} + +
+ {([["todo", `To do ${todo.length}${more}`], ["noapprover", `Needs an approver ${noApprover.length}${more}`], ["open", `Open ${open.length}${more}`], ["all", `All ${data.requests.length}${more}`], ["queries", `Record queries ${data.disputes.length}`], ["damage", `Damage ${data.damage.length}`], ["cycles", `Kit check & waitlist ${data.waiting.length}`]] as const).map(([k, lbl]) => ( + + ))} +
+ + {tab === "cycles" ? ( + <> +
Kit check
+ {data.cycle ? ( +
+
+ Running — due by {fmtDate(data.cycle.dueBy)} + + {data.cycle.answers} answer{data.cycle.answers === 1 ? "" : "s"} in · opened by {data.cycle.openedBy || "—"} + + act("kitcheck.close", { id: data.cycle!.id })} /> +
+
+ ) : ( +
+ {(c) => setDueBy(e.target.value)} />} + { if (await act("kitcheck.open", { dueBy })) setDueBy(""); }} /> + + Asks everyone holding uniform to confirm what they have. + +
+ )} + + {/* The answers themselves, which nothing in the product used to show. + People answer a kit check garment by garment, and the shortfalls are the only reason + to run one — a count of replies tells the linen room nothing it can act on. Written + down here, per person and per size, they are the working list for correcting the + register: open the record, hand in or write off the line, and the next cycle starts + from a register that is true. */} +
What people couldn't account for
+ {!data.cycle ? ( + No kit check is running. + ) : data.shortfalls.length === 0 ? ( + + {data.cycle.answers === 0 + ? "Nobody has answered yet." + : `Every one of the ${data.cycle.answers} answer${data.cycle.answers === 1 ? "" : "s"} so far matched the record.`} + + ) : ( + <> +
+ + + + + + + + + + + {data.shortfalls.map((f) => ( + + + + + + + {/* The number this table exists for. Marked as well as coloured: every other + figure in the row is a plain count, and what tells them apart across the + counter is the mark, not another shade of the brand red. */} + + + + + ))} + +
WhoGarmentSizeOn recordConfirmedShortAnswered +
{f.staffName} · {f.staffNum}{f.ward ? ` · ${f.ward}` : ""}{f.item}{f.size}{f.onRecord}{f.confirmed}{formatInZone(f.at, s.tz)}Open their record
+

+ Nothing here changes a record — open it and return the missing garments as “Written off”. +

+ + )} + +
Waiting for a size
+ {data.waiting.length === 0 && Nobody is waiting on a size.} + {data.waiting.map((w) => { + // The hold is a real deadline, not wording: lib/staffops refuses an accept once it has + // run out, so the counter has to be able to see that the garment is theirs to give to + // the next person rather than still being held for somebody who never came back. + const ends = holdEndsAt(w.offeredAt); + return ( +
+ {w.item} — {w.size} · {w.staffName}{w.ward ? ` (${w.ward})` : ""} + since {formatInZone(w.since, s.tz)} + {!w.offeredAt + ? act("waitlist.offer", { id: w.id })} /> + : holdExpired(w.offeredAt) + ? Hold lapsed — offer to the next person + : Held until {ends ? formatInZone(ends, s.tz, { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" }) : "—"}} +
+ ); + })} +

+ Offering tells them and holds the garment for {WAITLIST_HOLD_HOURS} hours. +

+ + ) : tab === "damage" ? ( + data.damage.length === 0 ? ( + Nothing reported damaged that hasn't come back yet. + ) : ( + <> +
+
{TAB_TITLE.damage}{plural(data.damage.length, "report", "reports")} still to come back
+ {data.damage.map((d, i) => ( +
+
+ + {d.item ? `${d.item}${d.size ? ` — ${d.size}` : ""}` : "Garment no longer on file"} + · {d.staffName} ({d.staffNum}{d.ward ? ` · ${d.ward}` : ""}) + + {d.kind} + {formatInZone(d.at, s.tz)} + act("damage.handedIn", { id: d.id })} /> +
+
+ {d.requestCode ? `Replacement requested — ${d.requestCode}` : "No replacement asked for"} + {d.photoId ? " · photo attached" : ""} + {" · "}Open their record +
+ {d.note &&

“{d.note}”

} + {d.photoId && ( + // eslint-disable-next-line @next/next/no-img-element + The damage as reported + )} +
+ ))} +
+

+ Handed in only clears the report — return the garment on their staff record. +

+ + ) + ) : tab === "queries" ? ( + data.disputes.length === 0 ? ( + Nobody has queried their record. + ) : ( +
+
{TAB_TITLE.queries}{plural(data.disputes.length, "record", "records")} somebody says is wrong
+ {data.disputes.map((d, i) => ( +
+
+ {d.staffName} ({d.staffNum}{d.ward ? ` · ${d.ward}` : ""}) + {formatInZone(d.at, s.tz)} + act("dispute.resolve", { id: d.id })} /> +
+

{d.body}

+
+ ))} +
+ ) + ) : ( + <> + {rows.length === 0 ? ( + + {tab === "todo" ? `Nothing approved and waiting${inLoaded}.` + : tab === "noapprover" ? `Every request waiting${inLoaded} has somebody to approve it.` + : data.moreRequests ? `Nothing here${inLoaded}.` : "Nothing here yet."} + + ) : ( +
+
+ {TAB_TITLE[tab]} + {plural(rows.length, "request", "requests")} +
+ {rows.map((r, i) => { + const st = statusText(r); + const isOpen = openId === r.id; + const awaiting = r.status === "awaiting"; + const orphan = stranded(r); + /* The dropdown belongs to the open row alone, and so does `choices`, which is drawn up + above for that row. Two requests waiting at the same moment can have different answers + — each leaves out whoever raised it — so it is the open row's question that gets asked. + `picked` is whoever is chosen in that dropdown; opening any row clears the choice. */ + const reachable = isOpen ? choices.filter((c) => c.reachable) : []; + const unreachable = isOpen ? choices.filter((c) => !c.reachable) : []; + const picked = isOpen && reassign ? s.staff.find((x) => x.id === reassign) ?? null : null; + /* Who answered, and what they answered. A request that got as far as the linen room was + approved, so the decision line carries the manager's name; a decline is left standing + on its own, because a withdrawal at the counter also lands here and attributing that + to the manager who was asked would be a lie on the face of the queue. + The approver being the person the request is for is allowed — a manager signs for her + own uniform the same as anybody's — and her record says so wherever it shows. This + queue said only a name, and a name that happens to match the one three words to its + left is not something anybody notices reading down a queue. Matched on the id, because + a ward can carry two people spelled the same way. */ + const wearerApproves = !!r.managerId && r.managerId === r.staffId; + const approval = awaiting + ? (r.managerName ? `with ${r.managerName}${wearerApproves ? " — their own request, theirs to approve" : ""}` : "nobody has been asked yet") + : r.status === "declined" + ? (r.decision || "declined") + : `${r.decision || "Approved"} by ${r.managerName}${wearerApproves ? " — their own request, self-approved" : ""}`; + /* The ward's order form, with this request's garments already on it. + * + * Not the same piece of paper as the collection slip. The slip travels with the bag and is + * what somebody signs at the handover; the order form is the record of the ask — the sheet + * the ward used to fill in by hand and send down, and the one a signature goes on. Printing + * it from the request is the only way the paper and the app can agree about the sizes, + * because the alternative is somebody copying them out again. The office-use block comes + * out blank: it is filled in at the counter and the app does not know any of it yet. + * + * It prints on an undecided request on purpose, and it is safe to: the form leaves off + * every declined line, so once the ward has answered it is the bag, and while the ward is + * still deciding there is nothing to leave off and it is the whole ask. What keeps the two + * apart on paper is the manager's block — it prints blank, with an unsigned rule where the + * delegate approves the sets, so an undecided request comes off the printer plainly + * unapproved. That is the one state the form is actually for: the sheet is what the + * request is short of, and it can be walked up to the ward and signed there. + * + * The code goes on the end of the spoken name, not in place of the visible words: somebody + * driving the counter by voice — hands full of garments, which is most of the shift — says + * what is written on the button, and a name that did not start with those words leaves them + * pressing nothing and wondering why. */ + const orderForm = ( + window.open(`/print/order-form?request=${encodeURIComponent(r.id)}`, "_blank", "noopener")} /> + ); + return ( + // A stranded request is not dimmed with the rest of the `awaiting` ones: it is the one + // kind of waiting the linen room is meant to act on, so it also takes the rule down its + // left edge that every other flagged thing in the app wears. +
+
+ {r.code} + + {r.summary} + · {r.staffName}{r.ward ? ` (${r.ward})` : ""} + + {orphan && No approver} + {st.label} + +
+ +
+ {[r.reason, approval, r.raisedByName ? `raised by ${r.raisedByName}` : "", formatInZone(r.createdAt, s.tz)].filter(Boolean).join(" · ")} +
+ + {isOpen && ( +
+ + {r.note &&

“{r.note}”

} + + {awaiting ? ( + <> +

+ {orphan + ? <>Nobody has been asked to approve this one — choose somebody who can. + : <>Waiting on {r.managerName}. If that answer is never coming, send it to somebody else or withdraw it.} +

+ {/* Without these two, a request addressed to a manager who never claimed an + account waits for ever: the wearer has no op that touches it and the manager + cannot sign in to decide it. */} +
+ + { if (reassign && await act("request.reassign", { id: r.id, managerId: reassign })) setReassign(""); }} /> + { if (confirm(`Withdraw ${r.code}? ${r.staffName} is told it was declined by the linen room.`)) act("request.withdraw", { id: r.id, reason: "Withdrawn — no approver available" }); }} /> + {orderForm} +
+ {/* A facility that has only just loaded its register has nobody on it who can + approve anything, and an empty dropdown beside a primary button reads as + a screen that is broken rather than as a register that is short. */} + {choices.length === 0 && ( +

+ Nobody on the register can approve this one — add the ward's managers on{" "} + the register, or withdraw it. +

+ )} + {/* Between choosing a name and pressing the button, which is the only moment + either of these can still be acted on. Afterwards the self-approval is on + the record, and the unreachable one is a request in Open under the name of + somebody who cannot answer it, with nothing anywhere saying so. */} + {picked && (picked.id === r.staffId || !picked.selfEmail) && ( +

+ {picked.id === r.staffId && ( + <>This is {picked.first}'s own request — sending it to them is a self-approval.{" "} + )} + {!picked.selfEmail && ( + <> + {picked.first} has no staff-app account, so can't be asked.{" "} + {picked.selfCode && slipLive(picked.selfCodeAt, s.today, s.tz) + ? <>A code on their record hasn't been used yet. + : picked.selfCode + ? <>The code on their record has expired — make a new one first. + : <>Give {picked.first} a code on their record first.} + + )} +

+ )} + + ) : ( +
+ {r.status === "accepted" && act("request.pick", { id: r.id })} />} + {r.status === "picking" && ( + <> + setHold(e.target.value)} /> + { if (await act("request.hold", { id: r.id, holdUntil: hold })) setHold(""); }} /> + act("request.round", { id: r.id })} /> + + )} + {r.status === "ready" && ( + <> + {/* One code for the whole bag, so the number of garments it covers is said + beside it — otherwise the person at the counter reads out a code, hands + over one garment and both of them think that was the lot. */} + Code {r.collectCode} · {plural(r.garments, "garment", "garments")}{r.holdUntil ? ` · until ${r.holdUntil}` : ""} + act("request.collected", { id: r.id })} /> + + )} + {r.status === "round" && On the round to {r.ward || "the ward"} — {plural(r.garments, "garment", "garments")}. The ward desk signs for it.} + {r.status === "delivered" && Signed by {r.signerName}{r.signerRole ? `, ${r.signerRole}` : ""}{r.claimedAt ? " · collected by the requester" : " · not yet collected from the ward"}} + {r.status === "collected" && Handed over at the counter.} + {r.status === "declined" && Declined — {r.declineReason || "no reason recorded"}.} + {/* The paper that travels with the bag. It lists the approved lines and the + collection code, so what is signed for is what was picked. */} + {r.status !== "declined" && ( + <> + openSlip(onRound(r) ? "delivery" : "collection", slipFor(r))} /> + {/* Nothing was ordered on a request every line of which was refused, so a + declined one gets no form — the same rule as the slip beside it. */} + {orderForm} + + )} +
+ )} + +
Messages
+ {r.messages.length === 0 &&
Nothing asked about this one.
} + {r.messages.map((m) => ( +
+ {m.fromStaff ? m.authorName : `${m.authorName} (linen room)`} + {formatInZone(m.at, s.tz)} +
{m.body}
+
+ ))} +
+ setReply(e.target.value)} /> + { if (reply.trim() && await act("request.reply", { id: r.id, body: reply })) setReply(""); }} /> +
+ +
History
+ {r.events.map((e) => ( +
+ {e.label}{e.meta ? ` — ${e.meta}` : ""} + {e.actorName} · {formatInZone(e.at, s.tz)} +
+ ))} +
+ )} +
+ ); + })} +
+ )} + {/* The queue is the newest few hundred requests and no more. Unsaid, a tab that has reached + the ceiling looks exactly like a tab that holds everything — the counts on the buttons + included — and somebody hunting a request from last winter concludes it was never + raised. The wearer's own record keeps its history separately, which is where a request + older than this cut-off is actually found. */} + {data.moreRequests && ( +

+ Only the most recent {data.requestLimit} requests are loaded — older ones are on{" "} + the wearer's staff record. +

+ )} + + )} + +
+ ); +} diff --git a/app/app/rounds/page.tsx b/app/app/rounds/page.tsx new file mode 100644 index 0000000..4dcd615 --- /dev/null +++ b/app/app/rounds/page.tsx @@ -0,0 +1,73 @@ +"use client"; +import { useState } from "react"; +import { useDerived, useSnap } from "@/lib/client"; +import { PageHead, Empty, KpiStrip, LiveRegion } from "@/components/ui"; +import { DeliverDialog } from "@/components/dialogs"; +import { ccOf, daysBetween, label, staffName, telHref, type PickupRec } from "@/lib/compute"; + +// Delivery rounds: everything awaiting pickup grouped by ward, ticked off on the floor with an on-screen signature. + +/* The Dashboard already counts pickups that have sat for a fortnight and puts the figure on the + front page, so the round sheet marks the same ones. Two screens disagreeing about which handover + is late is how a coordinator stops trusting either. */ +const STALE = 14; + +export default function RoundsPage() { + const { s } = useSnap(); + const { byId, staffById } = useDerived(); + const [deliver, setDeliver] = useState(null); + const [msg, setMsg] = useState(""); + const pending = s.pickups.filter((p) => !p.pickedUp); + const wards: Record = {}; + for (const p of pending) { const st = staffById[p.staffId]; const w = st?.dept || "Unknown"; (wards[w] = wards[w] || []).push(p); } + const wardNames = Object.keys(wards).sort(); + const garments = pending.reduce((t, p) => t + p.lines.reduce((n, l) => n + l.qty, 0), 0); + const stale = pending.filter((p) => daysBetween(p.received, s.today) >= STALE).length; + return ( +
+ + + {pending.length === 0 && Nothing waiting for delivery.} + {/* Zero old pickups is not news, so the third tile only takes the flag when there is something + to answer for. A rule and a mark that are always on the screen stop meaning anything. */} + {pending.length > 0 && 0, note: stale > 0 ? "Ring the ward if nobody is on shift to sign" : "Nothing has been sitting a fortnight" }, + ]} />} + {wardNames.map((w) => { + const rows = wards[w]; const st0 = staffById[rows[0].staffId]; + return ( +
+
+ {w} + CC {st0 ? ccOf(s, st0) || "—" : "—"} · {rows.length} to deliver +
+
+ {rows.map((p) => { + const st = staffById[p.staffId]; const tel = telHref(st?.phone); + const days = daysBetween(p.received, s.today); + const late = days >= STALE; + return ( + /* The days figure is the same number the line underneath says in words, so it is + read out once and not twice: the glance number is decoration, the sentence is + the message. */ +
+
+
{staffName(st, "Staff")} {tel ? {st?.phone} : {st?.phone}}
+
{p.lines.map((l) => `${label(byId[l.itemId])} ${l.size}${l.qty > 1 ? ` ×${l.qty}` : ""}`).join(", ")} · {p.orderCode}
+
{late &&
+
+ + +
+ ); + })} +
+
+ ); + })} + {deliver && setDeliver(null)} onDone={(m) => setMsg(m)} />} +
+ ); +} diff --git a/app/app/settings/page.tsx b/app/app/settings/page.tsx new file mode 100644 index 0000000..e20a27e --- /dev/null +++ b/app/app/settings/page.tsx @@ -0,0 +1,997 @@ +"use client"; +import { Suspense, useEffect, useMemo, useRef, useState } from "react"; +import TwoFactor from "@/components/TwoFactor"; +import SsoSettings from "@/components/SsoSettings"; +import PlanTab from "@/components/PlanTab"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useSnap } from "@/lib/client"; +import { PageHead, Dialog, ErrorLine, Field, LiveRegion, Seg } from "@/components/ui"; +import { parseCsv, CSV_TEMPLATES } from "@/lib/csv"; +import { LOCATION_KINDS, SET_GARMENTS, allowance, allowanceRoute, csvOf, daysBetween, fmtDate, groupKey, isKitGroup, isNursingGroup, kitGroupsOf, locMap, locPath, locTree, nursingGroupsOf, setsOnStart, type AllowanceRoute, type DeptRec, type SupplierRec, type UserRec } from "@/lib/compute"; +import { downloadCsv } from "@/lib/print"; + +// Plan is last and appears only once plans are live, for admins — see PlanTab. +const TABS = ["General", "Locations", "Departments", "Suppliers", "Account", "Sign-in", "Data", "Plan"] as const; +type Tab = (typeof TABS)[number]; +// The supplier details edited on the card below, which is also the shape of the keys their +// half-typed edits are filed under in `draft`. +type SupKey = "contact" | "phone" | "account" | "lead"; + +// Only used by a browser too old to have Intl.supportedValuesOf: the zones an Australian facility +// is actually in, so the picker is never empty on the one machine in the room that still runs it. +const FALLBACK_ZONES = ["Australia/Brisbane", "Australia/Sydney", "Australia/Melbourne", "Australia/Hobart", "Australia/Adelaide", "Australia/Darwin", "Australia/Perth", "Australia/Broken_Hill", "Australia/Lord_Howe"]; + +/* The three ways a staff group gets up to the ceiling, in the order a coordinator reads them. The + names are the ones the rest of the product uses for the routes, so a coordinator who reads + "Starting kit" here reads the same words on the order form and at the counter. */ +const ROUTES: { id: AllowanceRoute; label: string; now: string }[] = [ + { id: "fte", label: "FTE table", now: "on the FTE table" }, + { id: "kit", label: "Starting kit", now: "on the starting kit" }, + { id: "approval", label: "Manager approval", now: "on manager approval" }, +]; + +// Module-scope so React keeps the same element type across renders (defining it inside the page remounts the input on every keystroke). +function TextField({ label, hint, ph, value, onChange, disabled }: { label: string; hint?: string; ph?: string; value: string; onChange: (v: string) => void; disabled: boolean }) { + return {(c) => onChange(e.target.value)} disabled={disabled} />}; +} + +/* Also module-scope, and for a sharper reason than tidiness: this is a live region now, and a live + region that is torn down and rebuilt announces its contents again. Defined inside the page it + would be a fresh component type on every keystroke, so "Saved." would be read out over and over + while somebody typed in an unrelated box. */ +function Msg({ text }: { text?: string }) { + return ; +} + +// useSearchParams needs a Suspense boundary for static rendering. +export default function SettingsPage() { + return ; +} + +function SettingsInner() { + const { s, isAdmin, busy, mutate } = useSnap(); + const router = useRouter(); + const [tab, setTab] = useState("General"); + const sp = useSearchParams(); + const planShown = isAdmin && !!s.plan?.live && !s.demo; + const tabs = planShown ? TABS : TABS.filter((t) => t !== "Plan"); + // Deep links: ?tab=account (sidebar name), ?tab=data (dashboard setup card), ?tab=plan (the + // plan banner); #hash forms kept for old links. + useEffect(() => { + const want = (sp.get("tab") || window.location.hash.replace("#", "")).toLowerCase(); + const t = TABS.find((x) => x.toLowerCase() === want); + if (t && (t !== "Plan" || planShown)) setTab(t); + }, [sp, planShown]); + const [nl, setNl] = useState({ name: "", kind: "Shelf", parentId: "" }); + const [msg, setMsg] = useState>({}); + const say = (k: string, v: string) => setMsg((m) => ({ ...m, [k]: v })); + const [draft, setDraft] = useState>({}); + const timers = useRef>>({}); + useEffect(() => { const t = timers.current; return () => Object.values(t).forEach(clearTimeout); }, []); + /* `draft` as it stands now, rather than as it stood in the render that set a timer. A save that + fires after a pause carries the other boxes on the row along with it, and by the time it fires + the coordinator may have typed in one of them: a ward renamed and a cost centre typed straight + after used to save the new cost centre, then put the old one back a moment later when the + rename landed — and the ward's orders went on being costed to a number nobody meant any more. */ + const draftNow = useRef(draft); + useEffect(() => { draftNow.current = draft; }); + /* Take a half-typed edit back out of `draft`, so the box goes back to showing what the register + holds. Used where an edit is refused: a value nobody accepted must not be left on screen, where + it reads as saved and can be picked up by whatever else on the row saves the row. + `only` is the text that was refused, and the box is left alone if it no longer says that. A + refusal from the server arrives a moment after the name went to it, and by then the coordinator + may already be typing the correction — clearing the box then takes away letters nobody has so + much as looked at, mid-word, which reads as a field that eats what you type. What is left + behind is on its way to be checked in its own right, so nothing unchecked is left standing. */ + const forgetDraft = (k: string, only?: string) => setDraft((d) => { if (only !== undefined && d[k] !== only) return d; const next = { ...d }; delete next[k]; return next; }); + function debounced(k: string, v: string, op: string, payload: Record, msgKey = "fields") { + setDraft((d) => ({ ...d, [k]: v })); + clearTimeout(timers.current[k]); + timers.current[k] = setTimeout(async () => { const r = await mutate(op, payload); say(msgKey, r.ok ? "Saved." : r.error); }, 500); + } + const NUMERIC = ["defaultEntitlement", "initialSets", "defaultReorder", "exceptionHigh", "capSets", "varianceReason"]; + const setField = (k: string, v: string) => { if (NUMERIC.includes(k) && v === "") { setDraft((d) => ({ ...d, [k]: v })); return; } debounced(k, v, "settings.update", { [k]: v }); }; + const val = (k: keyof typeof s.settings) => (draft[k] !== undefined ? draft[k] : String(s.settings[k] ?? "")); + + const [newGroup, setNewGroup] = useState(""); + const [nd, setNd] = useState({ name: "", cc: "" }); + const [ns, setNs] = useState(""); + const [userDlg, setUserDlg] = useState(false); + const [pw, setPw] = useState({ current: "", next: "", again: "" }); + const [del, setDel] = useState({ open: false, password: "", confirm: "", busy: false, err: "" }); + const [me, setMe] = useState({ first: s.session.first, last: s.session.last, title: s.session.title }); + const meDirty = me.first !== s.session.first || me.last !== s.session.last || me.title !== s.session.title; + const [impKind, setImpKind] = useState("catalog"); + const [impBusy, setImpBusy] = useState(false); + const [wipe, setWipe] = useState(""); + const [reset, setReset] = useState(""); + /* The ward notice is written from here and read nowhere on this side of the product: the snapshot + carries no notice, so this box starts empty even while one is up on every wearer's home screen. + Said out loud under the field rather than left to be worked out, because an empty box meaning + "this screen can't see the board" and an empty box meaning "the board is empty" are not the + same thing to a coordinator deciding whether to post. */ + const [notice, setNotice] = useState({ body: "", endsAt: "" }); + const [noticeBusy, setNoticeBusy] = useState(false); + // Optimistic: the snapshot refresh lags the click, and a checkbox that snaps back reads as a failure. + const [lookupOn, setLookupOn] = useState(null); + const [tzPick, setTzPick] = useState(null); // same reason as lookupOn + /* And each group's route, for the same reason again. A route saves by sending both lists whole, + and until the refreshed snapshot lands the row still reads the old ones, so the route pressed + would spring back to the one before — read as a save that didn't take, and pressed again. Held + only while the snapshot still carries the lists it was worked out from: once those change, + whether from this save landing or from somebody else's, the snapshot is the truth again. Held + any longer, a group renamed since would still be here under its old name, and the next route + pressed would send that name back and take the renamed group off its route. */ + const [routePick, setRoutePick] = useState<{ base: string; nursing: string[]; kit: string[] } | null>(null); + const [renaming, setRenaming] = useState(null); + const [tzErr, setTzErr] = useState(""); + const [bkBusy, setBkBusy] = useState(false); + const [resetBusy, setResetBusy] = useState(false); + const [logoV, setLogoV] = useState(0); + /* The zone every date-only column in the product is written against — see facilityToday. A room + left on the Brisbane default gets its evenings filed against tomorrow: a Perth issue at 22:30 on + 30 June counts against the next financial year's entitlement and drops out of June's exceptions + report and cost-centre journal. The names come from this browser's zone table; settings.update + checks a submitted name against the server's, and the two are not guaranteed to be the same list + — an older Node, or a browser new enough to offer a zone the server's ICU data predates, and the + server refuses something this select happily offered. Rare, and not something the client can + check for, so the refusal is put under the select instead of being left to a message further + down the page. The current zone is prepended if this browser has never heard of it, so a + facility can always see what it is on. */ + const zones = useMemo(() => { + // Optional call on purpose: TypeScript's lib says this exists, the browser in the linen room + // may disagree. + const all = Intl.supportedValuesOf?.("timeZone") || FALLBACK_ZONES; + return all.includes(s.settings.timezone) ? all : [s.settings.timezone, ...all]; + }, [s.settings.timezone]); + + /* The facility's own two lists and nothing else. An empty one means no group is on that route: + there is no list of ours standing in for it, so nothing on this screen may behave as if there + were. */ + const storedLists = { nursing: nursingGroupsOf(s), kit: kitGroupsOf(s) }; + const listSig = JSON.stringify([storedLists.nursing, storedLists.kit]); + const lists = routePick && routePick.base === listSig ? routePick : storedLists; + // The same answer the counter, the order form and the wearer's own app reach, including the FTE + // table winning for a group somehow on both lists. + const routeOf = (g: string) => allowanceRoute({ nursing: isNursingGroup(lists.nursing, g), kit: isKitGroup(lists.kit, g) }); + const routeNow = (r: AllowanceRoute) => ROUTES.find((x) => x.id === r)?.now ?? ""; + /* Everybody still working, counted by the group they are filed under and compared the way the app + compares group names. It is what the remove button has to warn about, and what the list of + groups nobody has added yet is built from. */ + const filedUnder: Record = {}; + const spelt: Record = {}; + for (const st of s.staff) { + const k = groupKey(st.group); + if (st.inactive || !k) continue; + filedUnder[k] = (filedUnder[k] || 0) + 1; + if (!spelt[k]) spelt[k] = st.group.trim(); + } + const staffCount = (g: string) => filedUnder[groupKey(g)] || 0; + /* The groups on the list, then any name still on a route that is no longer on the list — left + there by a backup restored from an older file, or by the move to three routes. The people filed + under it are still on that route, so it stays in sight to be kept or let go, rather than + deciding somebody's kit from a list nobody can see. */ + const listedKeys = new Set(s.settings.staffGroups.map(groupKey)); + const offList: string[] = []; + for (const g of [...lists.nursing, ...lists.kit]) { + const k = groupKey(g); + if (!listedKeys.has(k) && !offList.some((x) => groupKey(x) === k)) offList.push(g); + } + const groupRows = [...s.settings.staffGroups.map((g) => ({ g, listed: true })), ...offList.map((g) => ({ g, listed: false }))]; + /* Groups people are filed under that nobody has put on this list. A staff import files people + under whatever the roster calls them and adds nothing here, so on a facility that has just + loaded its register this is every group it has — and everybody in them is on manager approval + until the group is added and given a route. Named with a button each, biggest first, rather + than left for somebody to notice. */ + const rowKeys = new Set(groupRows.map((r) => groupKey(r.g))); + const unlisted = Object.keys(filedUnder).filter((k) => !rowKeys.has(k)).map((k) => ({ g: spelt[k], n: filedUnder[k] })).sort((a, b) => b.n - a.n); + + /* The figures the routes are described with, read off the boxes rather than the stored values, so + the words describe what walking away from this screen now would leave in force. A box left empty + saves nothing, so the stored figure stands for it. Put through allowance() — the sum the counter + and the wearer's app do — so a starting kit typed above the ceiling is quoted at the ceiling, + which is what the counter actually hands over. */ + const typedSets = (k: "initialSets" | "capSets") => { const t = val(k).trim(); return t === "" ? s.settings[k] : Number(t); }; + const shape = allowance({ held: 0, kit: true, startingSets: typedSets("initialSets"), capSets: typedSets("capSets") }); + const ceiling = shape.max, kitStart = shape.start ?? 0; + const kitOverCeiling = setsOnStart(typedSets("initialSets")) > ceiling; + /* Sets and garments together, because they are one kit counted two ways and the argument at the + counter is always about garments. SET_GARMENTS rather than a bare 2: a set is a top and a bottom + everywhere in the product, and this is not the place to re-decide it. */ + const sets = (n: number) => `${n} set${n === 1 ? "" : "s"} (${n * SET_GARMENTS} garments)`; + const routeSays: Record = { + fte: "First kit proposed from each person's hours; a manager can sign for more.", + kit: `${sets(kitStart)} on the first day, then more as needed.`, + approval: "Nothing on the first day; a manager approves each set.", + }; + + async function signOut() { await fetch("/api/auth/logout", { method: "POST" }); router.push("/auth"); router.refresh(); } + /* One change to the groups at a time. Each of these sends whole lists worked out from what is on + screen, so a second one sent before the first is back in the snapshot is worked out from the + lists as they were: an add straight after a rename would send the old name back, and the renamed + group would come off its route with it. `busy` covers the save and the refresh behind it, which + is well under a second. Refused out loud rather than by greying the buttons, because a button + switched off under the finger drops keyboard focus on the floor. */ + const settled = () => { if (!busy) return true; say("groups", "Still saving the last change — try again in a moment."); return false; }; + // Editing the group list, a ward or a supplier used to fire and forget: a refusal (the last group, + // a name already taken, a lost connection) left the chip sitting where it was with nothing said, + // and the admin clicked again. `name` is for the buttons that add a group somebody is already + // filed under; the box below the list sends nothing and is cleared once its group is in. + async function addGroup(name?: string) { + const g = (name ?? newGroup).trim(); + if (!g || !settled()) return; + const route = routeOf(g); + const r = await mutate("settings.update", { staffGroups: [...s.settings.staffGroups, g] }); + say("groups", r.ok ? `${g} added, ${routeNow(route)}${route === "approval" ? " until you choose another route" : ""}.` : r.error); + if (r.ok && name === undefined) setNewGroup(""); + } + async function removeGroup(g: string) { + if (!settled()) return; + const n = staffCount(g), route = routeOf(g); + const who = `${n} staff member${n === 1 ? " is" : "s are"} filed under ${g}`; + /* Asked before, not reported after. Taking a group off the list takes it off its route too, and + everybody still filed under it goes onto manager approval — a coordinator tidying up a list is + owed that before a team's first kit goes, not in a message once it has. */ + if (n && !confirm(route === "approval" + ? `${who}. They stay filed under it, still on manager approval, but nobody new can be put in ${g}. Take it off the list?` + : `${who}, which is ${routeNow(route)}. Taking it off the list puts them on manager approval — move them to another group first to keep their route.\n\nTake ${g} off the list?`)) return; + const r = await mutate("settings.update", { staffGroups: s.settings.staffGroups.filter((x) => x !== g) }); + say("groups", !r.ok ? r.error + : n ? `${g} removed. The ${n} staff member${n === 1 ? " filed under it is" : "s filed under it are"} on manager approval, and ${g} is listed below to add back.` + : `${g} removed.`); + } + // Both lists in one save, because moving a group is taking it off one route and putting it on + // another, and the server refuses any save that would leave it on two. + async function setRoute(g: string, to: AllowanceRoute) { + const from = routeOf(g); + if (from === to || !settled()) return; + const k = groupKey(g); + const nursing = lists.nursing.filter((x) => groupKey(x) !== k); + /* A group caught on both lists is on the FTE table already — allowanceRoute() says so — so + taking it off the kit list here changes nobody's route. What it does is let the save through: + the server refuses any save that leaves a group on both, whichever group the click was about. */ + const kit = lists.kit.filter((x) => groupKey(x) !== k && !isNursingGroup(nursing, x)); + if (to === "fte") nursing.push(g); + if (to === "kit") kit.push(g); + setRoutePick({ base: listSig, nursing, kit }); + const r = await mutate("settings.update", { nursingGroups: nursing, kitGroups: kit }); + // Put the row back where the server still has it, or the screen would go on claiming a change + // that was refused. + if (!r.ok) { setRoutePick(null); say("groups", r.error); return; } + say("groups", `${g} is ${routeNow(to)}. ${routeSays[to]}`); + } + /* The one message the linen room can put in front of everybody at once. It is not mail and it is + not a request: it is the board on the wall, and the ward reads it on the home screen of their + own app. An empty message takes the board down — the linen room's way of saying that's over. */ + async function postNotice() { + const body = notice.body.trim(), endsAt = notice.endsAt.trim(); + /* A day already gone is a notice nobody will ever see: the staff app only shows one whose end + date is today or later. Caught here, because the server takes the date happily and the first + anyone would know of it is that the ward never mentioned the thing they were told. */ + if (body && endsAt && endsAt < s.today) { say("notice", `${fmtDate(endsAt)} has already gone, so nobody would see this. Pick today or later, or leave the date blank.`); return; } + setNoticeBusy(true); + const r = await mutate<{ cleared: boolean }>("notice.set", { body, endsAt }); + setNoticeBusy(false); + if (!r.ok) { say("notice", r.error); return; } + say("notice", r.result.cleared + ? "The board is clear. Nothing shows on anybody's home screen now." + : `Posted. Every staff member who has set up the app sees this on their home screen${endsAt ? `, up to and including ${fmtDate(endsAt)}` : ", until it is taken down"}.`); + } + /* Renaming a ward is safe to offer because dept.save carries the old name forward in the same + transaction: every staff record filed under it and every order costed to it moves with it, so + nothing is left pointing at a name that has gone. + + A rename that is not going to happen has to leave the row showing the ward the register still + has. It used to leave the rejected text sitting in the box, which is worse than not checking at + all: the coordinator walks away reading a ward name that exists nowhere, and the cost centre box + beside it saves the whole row — so the next cost centre typed on that row was the thing that + finally saved the name nobody accepted. Every ending here either saves or puts the name back, + and says which — and either way it says so, because a name that was refused was refused whether + or not a better one is already being typed over it. */ + function renameDept(d: DeptRec, v: string) { + const k = "deptname:" + d.id; + setDraft((x) => ({ ...x, [k]: v })); + clearTimeout(timers.current[k]); + /* Checked when the typing stops rather than on every keystroke, because half a ward's name on + the way to a whole one is not a refusal — clearing the box under somebody mid-word would make + the field unusable. The pause is the same one that commits the save. */ + timers.current[k] = setTimeout(async () => { + const name = v.trim(); + const no = deptNameRefusal(d, name); + if (no) { forgetDraft(k, v); say("depts", no); return; } + const r = await mutate("dept.save", { id: d.id, name, cc: deptCc(d, draftNow.current).trim() }); + if (!r.ok) { forgetDraft(k, v); say("depts", r.error); return; } + say("depts", `Renamed to ${name}. Everyone filed under ${d.name}, and every order costed to it, moved with it.`); + }, 600); + } + /* The catch is the whole point of parseCsv refusing a malformed file. It throws to stop a + half-import, and without somewhere to land that refusal was an unhandled rejection: the admin + saw "Importing…" sit there forever and went looking for the staff it never loaded. Whatever + parseCsv says — which line, which quote — is what the admin needs on screen to fix the file. */ + async function importFile(file: File) { + setImpBusy(true); say("import", "Importing…"); + try { + const rows = parseCsv(await file.text()); + if (!rows.length) { say("import", "No rows found — check the header row."); return; } + const r = await mutate<{ created: number; updated: number; skipped: number; styles?: number; errors: string[] }>("import.rows", { kind: impKind, rows }); + if (!r.ok) { say("import", r.error); return; } + const x = r.result; + say("import", `${CSV_TEMPLATES[impKind].name}: ${x.created} created, ${x.updated} updated, ${x.skipped} skipped.${x.styles ? ` ${x.styles} uniform ${x.styles === 1 ? "style" : "styles"} set.` : ""}` + (x.errors.length ? "\n" + x.errors.join("\n") : "")); + } + catch (e) { say("import", (e as Error)?.message || "That file couldn’t be read as a CSV. Nothing was imported."); } + finally { setImpBusy(false); } + } + async function restore(file: File) { + if (!confirm("Restore this backup? It replaces ALL data in this facility (catalogue, staff, orders, issues, stock, approvals). Users are kept.")) return; + say("backup", "Restoring…"); + try { + const data = JSON.parse(await file.text()); + const r = await mutate<{ photosSkipped: number }>("backup.restore", data); + if (!r.ok) { say("backup", r.error); return; } + // A restore takes back a capped number of photos and drops the rest rather than refusing the + // whole file. Said out loud, because the alternative is a room believing every signature and + // damage photo is back on the record when some of them only exist in the file. + const skipped = r.result?.photosSkipped || 0; + say("backup", skipped ? `Backup restored — every record came back, but ${skipped} photo${skipped === 1 ? "" : "s"} in the file did not. Keep the backup file: those images are only in it now.` : "Backup restored."); + } + catch (e) { say("backup", "Import failed — " + (e as Error).message); } + } + /* Fetched rather than a plain , because a browser downloading a file never + shows the page its contents: the export trims the oldest photos to keep the file inside what a + restore will take back, counts them in `photosOmitted`, and until this ran through fetch nobody + was ever told. A room finds out otherwise only on the day it restores. */ + async function exportBackup() { + setBkBusy(true); say("backup", "Preparing the backup…"); + try { + const res = await fetch("/api/backup"); + /* Every other write on this page goes through mutate, which sends a dead session back to the + sign-in door; this one fetch was outside that and would have handed the admin a signed-out + error page saved as threadcount-backup.json — a file that looks like a backup and restores + nothing. Same destination as mutate's, carrying where they were so they land back here. */ + if (res.status === 401) { window.location.assign(`/auth?next=${encodeURIComponent(location.pathname + location.search)}`); return; } + if (!res.ok) { say("backup", ((await res.json().catch(() => ({}))) as { error?: string }).error || "Export failed — nothing was downloaded."); return; } + const text = await res.text(); + // Read out of the text rather than JSON.parse: the file carries every photo that travelled and + // can run to tens of megabytes, and parsing it a second time on a linen-room PC to learn one + // number is not worth the memory. + const omitted = Number(/"photosOmitted":\s*(\d+)/.exec(text)?.[1] || 0); + const name = /filename="([^"]+)"/.exec(res.headers.get("content-disposition") || "")?.[1] || "threadcount-backup.json"; + const url = URL.createObjectURL(new Blob([text], { type: "application/json" })); + const a = document.createElement("a"); a.href = url; a.download = name; a.click(); + setTimeout(() => URL.revokeObjectURL(url), 60_000); + say("backup", omitted + ? `${name} downloaded. ${omitted} older photo${omitted === 1 ? " was" : "s were"} left out so the file stays small enough to restore — every record is in it, and the images stay on the server.` + : `${name} downloaded.`); + router.refresh(); + } catch (e) { say("backup", "Export failed — " + (e as Error).message); } + finally { setBkBusy(false); } + } + function template(kind: string) { + const t = CSV_TEMPLATES[kind]; + const a = document.createElement("a"); a.href = "data:text/csv;charset=utf-8," + encodeURIComponent(t.headers + "\n" + t.example + "\n"); a.download = `threadcount-${kind}-template.csv`; a.click(); + } + function uploadLogo(file: File) { + if (file.size > 400 * 1024) { say("logo", "Logo must be under 400 KB."); return; } + const r = new FileReader(); + r.onload = async () => { const res = await mutate("settings.update", { logoData: String(r.result) }); setLogoV((v) => v + 1); say("logo", res.ok ? "Logo saved — it prints top-right on slips." : res.error); }; + r.readAsDataURL(file); + } + // How many sizes sit on each location, so an empty shelf is obvious before it is deleted. + const locCounts: Record = {}; + for (const k in s.placed) locCounts[s.placed[k]] = (locCounts[s.placed[k]] || 0) + 1; + const H = ({ children, top = 6 }: { children: React.ReactNode; top?: number }) =>
{children}
; + const Note = ({ children }: { children: React.ReactNode }) =>
{children}
; + /* Not a component defined in here. React compares element types by identity, so a helper declared + inside the render is a brand-new type on every keystroke: the whole field is torn down and + rebuilt, and the caret goes with it. TextField sits at module scope and is handed everything it + needs, which is why F is a plain function returning an element rather than . */ + const F = (k: keyof typeof s.settings, label: string, opts: { ph?: string; hint?: string; numeric?: boolean; demoFixed?: boolean } = {}) => + setField(k, opts.numeric ? v.replace(/[^0-9]/g, "") : v)} />; + const grid: React.CSSProperties = { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-4)", marginTop: "var(--space-3)" }; + const deptStaff: Record = {}; for (const st of s.staff) deptStaff[st.dept] = (deptStaff[st.dept] || 0) + 1; + + /* One reading of a field, used both by the input that edits it and by the export that writes it, + so the two cannot drift apart. debounced() holds a keystroke in `draft` for half a second before + it reaches the server, and `draft` is what the coordinator can see in the box — so `draft` is + what the file has to say. Overlaying it here rather than flushing the pending saves first is + deliberate: pressing Export must not write to the register (a half-typed cost centre would be + committed early), it must not wait on the network to hand over a file, and a save the server + refuses leaves the typed value on screen anyway — only the overlay still matches it. The ward's + own name is the exception, and exportDepts says why. A save that goes out after a pause passes + draftNow instead, for the reason given where that is kept. */ + const deptCc = (d: DeptRec, from = draft) => from["dept:" + d.id] ?? d.cc; + /* The same overlay for the ward's own name, and for a second reason on top of the export's: the + cost centre box beside it saves the whole row, name included, so without this a cost centre + typed while a rename was still settling would quietly put the old name back. */ + const deptName = (d: DeptRec) => draft["deptname:" + d.id] ?? d.name; + /* What is wrong with a ward name, in the words the coordinator needs, or nothing if it is fine. + One reading of it, because two boxes on the row both save the row — the name and the cost centre + — and if they disagreed about what counts as a name, the cost centre box would be the way a + rejected name got saved anyway. The register itself refuses both of these; asked here as well so + the answer arrives while the coordinator is still looking at the row they typed it on. */ + function deptNameRefusal(d: DeptRec, name: string) { + if (!name) return `A ward needs a name — ${d.name} hasn’t been changed.`; + const clash = s.depts.find((o) => o.id !== d.id && o.name.trim().toLowerCase() === name.toLowerCase()); + return clash ? `${clash.name} is already on the list, and two wards with one name can’t be told apart on a staff record or a journal line.` : ""; + } + /* The name this row would be saved under: what has been typed, unless it is a name the register + would refuse, in which case the ward keeps the one it has. Typing a cost centre must never be + the thing that commits a rename. It is for the save alone — what the file hands to finance is + the name the register actually holds, see exportDepts. */ + const deptSaveName = (d: DeptRec) => { const n = deptName(d).trim(); return deptNameRefusal(d, n) ? d.name : n; }; + const supField = (sup: SupplierRec, k: SupKey) => draft[`sup:${sup.id}:${k}`] ?? (sup[k] === null ? "" : String(sup[k])); + + /* The three registers on this page are the ones a coordinator is most often asked to hand over — + the shelf map before a stocktake, the ward list for finance, the supplier list for procurement — + and until now the only way out of any of them was to retype what was on the screen. Each tab + exports its own register and nothing else: somebody on Suppliers pressing Export means suppliers. */ + function exportLocations() { + /* Nothing to overlay here, unlike the two below: the only editable thing on this tab is the + Inside select, and that is saved the moment it changes rather than held in `draft`. */ + const byId = locMap(s); + /* A tree flattened into rows loses the thing that made it a tree, and "Bay B3" on its own is no + use to anybody walking the room — there is a B3 on every shelf. So each row carries its full + path as well as its own name, built with the helper the rest of the app renders a location + with, and a spreadsheet sorted any which way still reads Linen Room · Shelf B · Bay B3. */ + const rows = locTree(s, true).map(({ loc }) => [loc.name, loc.kind, loc.parentId ? byId[loc.parentId]?.name ?? "" : "", locPath(byId, loc.id).map((l) => l.name).join(" · "), locCounts[loc.id] || 0]); + downloadCsv(`threadcount-locations-${s.today}.csv`, csvOf(["Location", "Kind", "Inside", "Full path", "Sizes"], rows)); + } + /* dept and cc are the import template's own headers, not prettier ones that happen to normalise + onto them, so what comes out of here is exactly what the importer expects back: a coordinator + can export the wards, fix twenty cost centres in a spreadsheet and import the same file under + Data without touching the header row. The staff count is ours to be useful — the importer has + no alias for it, so it is ignored on the way back in and cannot create a ward of its own. */ + function exportDepts() { + /* The ward's name as the register holds it, not as the box reads it. A rename is half a second + behind the typing and the server can still turn it down after that, so a name in the box is + not yet a ward. This file goes to finance and comes back in through Data, where a name that + never landed arrives as a ward of its own: the staff stay on the old one, the new cost centre + goes on the new one, and the ward is in two halves. The cost centre beside it is the typed + one on purpose — a code is typed into a ward that already exists, so the worst an unsaved one + does is carry a correction to finance a moment early. + Trimmed the way dept.save trims, so a code typed with a stray trailing space — invisible in + the box — reaches finance in the form the register will actually hold. */ + downloadCsv(`threadcount-departments-${s.today}.csv`, csvOf(["dept", "cc", "staff"], s.depts.map((d) => [d.name, deptCc(d).trim(), deptStaff[d.name] || 0]))); + } + // Everything procurement rings a supplier about: who to ask for, on which account, and how long + // they take — lead time being what dates the delivery on a new order. The product and order counts + // are the screen's own, and they say which of these names anybody is actually buying from. + function exportSuppliers() { + const rows = s.supplierDir.map((sup) => [sup.name, supField(sup, "contact"), supField(sup, "phone"), supField(sup, "account"), supField(sup, "lead"), s.catalog.filter((it) => it.supplier === sup.name).length, s.orders.filter((o) => o.supplier === sup.name).length]); + downloadCsv(`threadcount-suppliers-${s.today}.csv`, csvOf(["Supplier", "Contact", "Phone", "Account no.", "Lead time (days)", "Products", "Orders"], rows)); + } + const lastBk = s.settings.lastBackup; const bkDays = lastBk ? daysBetween(lastBk, s.today) : null; + /* A week is the line. Past it the facility is one failed disk away from retyping its register by + hand, which is the only thing on this page worth interrupting somebody over. */ + const bkStale = !lastBk || (bkDays ?? 0) > 7; + + return ( + /* The ink band runs the full width of the content column, so the reading measure is set on the + form underneath it rather than on the section. Set here, the head would bleed out to the left + gutter and stop dead at 760px on the right. */ +
+ +
+ {/* Seg rather than a hand-rolled strip, for what Seg carries: aria-pressed. Which tab you are + on used to be a fill colour and nothing else, so a coordinator on a screen reader heard six + identical buttons, and tapping one announced no change at all. */} + + + {tab === "General" && ( + <> + Facility +
+ {F("facility", "Facility")}{F("location", "Stock location")}{F("coordinator", "Coordinator name")} + {/* Fixed in the demo for the same reason the coordinator's name is, with more riding on + it: everyone shares that one facility, so an address or number typed here prints in + the foot of the order form in front of every other visitor — and it would be a real + person's address and a real phone. */} + {F("coordinatorEmail", "Coordinator e-mail", { ph: "e.g. uniforms@yourhospital.org.au", demoFixed: true })} + {F("coordinatorPhone", "Coordinator phone", { ph: "e.g. 07 3xxx xxxx", demoFixed: true })} + {/* Off in the demo for the same reason settings.update refuses the facility name and the + slip footers there: everyone shares that one facility, so a visitor setting it to + Honolulu re-dates the dashboard, the exceptions report and the journal for every + other visitor — the director of nursing and the Play reviewer included. */} + {(c) => ( + + )} +
+ These print on slips, purchase orders, reports and the uniform order form. + Issuing & stock +
{F("capSets", "Ceiling, every group (sets)", { numeric: true, ph: "e.g. 6", hint: "Held at any time — not a yearly allowance." })}{F("initialSets", "Starting kit (sets)", { numeric: true, ph: "e.g. 3", hint: "First day, Starting kit route only." })}{F("defaultEntitlement", "Yearly figure for reports (garments)", { numeric: true })}{F("defaultReorder", "Default reorder level", { numeric: true, hint: "For sizes without their own." })}{F("exceptionHigh", "Exception threshold (items/month)", { numeric: true })}
+ {kitOverCeiling && Nobody is handed more than the ceiling, so the starting kit stops at {sets(kitStart)}.} + {/* Set on the counter phone and nowhere else until now, which meant the one number the + desktop stock take enforces could only be changed by somebody holding the phone — + and on a facility whose phones are all issued out, not at all. */} + Stock takes +
{F("varianceReason", "Reason required at (garments)", { numeric: true, ph: "e.g. 5", hint: "Over or short, here and on the counter phone." })}
+ Finance & journal +
{F("glAccount", "GL account", { ph: "e.g. 631020" })}{F("journalDesc", "Journal description prefix", { ph: "e.g. Uniform issues" })}
+ Used by the Reports journal export and month-end pack. + Slips & logo +
+ {F("slipOrg", "Organisation name on slips", { ph: "Printed when there is no logo" })} + {/* Not a Field: this cell holds a preview, a file picker and a remove button, and a single +
+ + Staff groups + Each group takes one route; every route stops at the ceiling of {sets(ceiling)} held. + {/* One sentence per route, read once here rather than repeated down every row, and written + with this facility's own figures — the ones the counter and the wearer's app quote. */} +
+ {ROUTES.map((r) =>
{r.label}. {routeSays[r.id]}
)} +
+ {/* Where every new facility starts: it names its own groups, and there is no list of ours to + stand in for them. Until it does, the only route anybody is on is manager approval, and + that is said here rather than left as an empty space to be puzzled over. */} + {!groupRows.length && ( +
+
+ )} + {!!groupRows.length && ( +
+ {groupRows.map(({ g, listed }) => { + const route = routeOf(g), n = staffCount(g); + return ( +
+
+ {g} +
{n ? `${n} staff member${n === 1 ? "" : "s"}` : "Nobody filed under it"}{listed ? "" : " · not on the list"}
+
+ {/* Seg's markup rather than Seg itself, because Seg's buttons can't be switched off + for an issuer, who may read the routes but not change them. One pressed button + out of three is also what makes a group on two routes impossible to ask for. */} +
+ {ROUTES.map((r) => )} +
+ {isAdmin && ( +
+ {listed + ? + : } + {/* A bare "×" announces as "times, button" and names nothing, so a screen-reader + user had no way to tell which group they were about to delete. */} + {listed && } +
+ )} +
+ ); + })} +
+ )} + {isAdmin &&
setNewGroup(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && newGroup.trim()) addGroup(); }} />
} + {!!offList.length && Groups not on the list keep their route but take nobody new — add one back to keep it.} + {!!unlisted.length && ( +
+ {unlisted.length === 1 ? "This group is" : "These groups are"} on the staff register but not on this list, so {unlisted.length === 1 ? "its" : "their"} staff are on manager approval until added{isAdmin ? "" : " by an admin"}. +
+ {unlisted.map(({ g, n }) => isAdmin + ? + : {g} · {n})} +
+
+ )} + + + {isAdmin && ( + <> + Ward notice + Shown on the home screen of the staff app. + {(c) =>